Examples · Internet of Things
Reading an analog gauge
Python draws eight pressure gauges at values you know, and two vision models read them, answering in a JSON schema with a number and a confidence. Your code measures the error of every reading, and flags the gauges where the two models disagree.
Intermediate · 35 min
The idea
Many buildings still have analog gauges: the pressure of the heating circuit in the boiler room, the water meter, an old thermometer. Replacing them costs money. A cheap camera that takes a photo every hour, and a model that reads the needle, can turn them into data.
Can you trust the number? You can only answer that if you know the true value. So here Python draws the gauges, at values that only your code knows, and then:
- two vision models read every gauge, as JSON: a value and a confidence;
- your code compares each reading with the truth, and prints the error;
- a check that needs no truth flags the gauges where the two models disagree.
What you need
A key and the openai library, as in Your first API call in Python, and Pillow, to draw:
pip install pillow
The gauges
Save this as draw_gauges.py and run it once with python3 draw_gauges.py. It writes eight pictures, gauge_1.png to gauge_8.png, and the true values in truth.json. Open a couple of pictures: can you read them to 0.1 bar?
import json
import math
from PIL import Image, ImageDraw, ImageFont
# The true values: the model never sees this file, only the pictures.
VALUES = [0.6, 1.8, 2.5, 3.7, 4.4, 6.1, 7.9, 9.3]
SIZE, CENTRE, RADIUS = 400, 200, 170
FONT = ImageFont.load_default(size=26)
SMALL = ImageFont.load_default(size=18)
def angle(value):
"""0 bar is down-left, 10 bar is down-right: 270 degrees, clockwise."""
return math.radians(135 + value * 27) # Pillow measures angles clockwise from 3 o'clock
def point(value, r):
a = angle(value)
return CENTRE + r * math.cos(a), CENTRE + r * math.sin(a)
def draw_gauge(value, path):
img = Image.new("RGB", (SIZE, SIZE), "white")
d = ImageDraw.Draw(img)
d.ellipse([CENTRE - RADIUS - 12] * 2 + [CENTRE + RADIUS + 12] * 2, outline="black", width=6)
d.arc([CENTRE - RADIUS + 4] * 2 + [CENTRE + RADIUS - 4] * 2, 135 + 8 * 27, 135 + 270, fill="red", width=10)
for step in range(51): # a mark every 0.2 bar, a long one with a number every 1 bar
v = step / 5
major = step % 5 == 0
d.line([point(v, RADIUS - (28 if major else 14)), point(v, RADIUS)], fill="black", width=4 if major else 2)
if major:
d.text(point(v, RADIUS - 52), str(int(v)), fill="black", font=FONT, anchor="mm")
d.text((CENTRE, CENTRE + 60), "bar", fill="black", font=FONT, anchor="mm")
d.text((CENTRE, CENTRE + 95), "BOILER ROOM", fill="gray", font=SMALL, anchor="mm")
tail = (2 * CENTRE - point(value, 25)[0], 2 * CENTRE - point(value, 25)[1])
d.line([tail, point(value, RADIUS - 20)], fill="black", width=5) # the needle
d.ellipse([CENTRE - 12, CENTRE - 12, CENTRE + 12, CENTRE + 12], fill="black")
img.save(path)
truth = {}
for n, value in enumerate(VALUES, 1):
path = f"gauge_{n}.png"
draw_gauge(value, path)
truth[path] = value
with open("truth.json", "w") as f:
json.dump(truth, f, indent=2)
print(f"{len(truth)} gauges drawn, true values in truth.json")
The scale is the usual one: 270 degrees, from 0 at the bottom left to 10 at the bottom right, with a red zone from 8 bar.
The program
Save it as read_gauges.py, in the same folder:
import base64
import json
import os
from openai import OpenAI
client = OpenAI(
base_url="https://api.ai.itsincom.org/v1",
api_key=os.environ["API_KEY"],
)
MODELS = ["gemma-4-26b", "qwen3.6-35b"]
schema = {
"type": "object",
"properties": {
"confidence": {"type": "string", "enum": ["high", "medium", "low"]},
"value": {"type": "number"},
},
"required": ["confidence", "value"],
}
def read_gauge(model, path):
with open(path, "rb") as f:
image = base64.b64encode(f.read()).decode()
answer = client.chat.completions.create(
model=model,
temperature=0,
max_tokens=300,
messages=[{
"role": "user",
"content": [
{"type": "text", "text": "This is a pressure gauge, scale 0 to 10 bar, with a small mark every "
"0.2 bar. The scale goes clockwise: 0 is at the bottom left, 5 at the "
"top, 10 at the bottom right. Read the value where the needle points, "
"to one decimal."},
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{image}"}},
],
}],
response_format={"type": "json_schema", "json_schema": {"name": "reading", "schema": schema}},
)
if answer.choices[0].finish_reason != "stop":
raise SystemExit(f"{model}, {path}: the answer was cut off")
return json.loads(answer.choices[0].message.content)
with open("truth.json") as f:
truth = json.load(f)
errors = {model: [] for model in MODELS}
print("gauge true " + "".join(f"{model:>21}" for model in MODELS))
for path, true_value in truth.items():
line = f"{path} {true_value:4.1f} "
values = []
for model in MODELS:
reading = read_gauge(model, path)
error = reading["value"] - true_value
errors[model].append(abs(error))
values.append(reading["value"])
line += f" {reading['value']:4.1f} ({error:+4.1f}) {reading['confidence']:>6}"
# The check you can make without knowing the truth: do the two readers agree?
if max(values) - min(values) > 0.5 or not all(0 <= v <= 10 for v in values):
line += " <-- ask a person"
print(line)
for model in MODELS:
e = errors[model]
print(f"{model}: mean error {sum(e) / len(e):.2f} bar, worst {max(e):.1f} bar, "
f"{sum(round(x, 1) <= 0.2 for x in e)} of {len(e)} within 0.2 bar")
Run it with python3 read_gauges.py. Sixteen requests go out, one at a time, eight for each model: in our runs it took about ten seconds.
What you should see
gauge true gemma-4-26b qwen3.6-35b
gauge_1.png 0.6 1.2 (+0.6) high 4.0 (+3.4) high <-- ask a person
gauge_2.png 1.8 2.1 (+0.3) high 1.8 (+0.0) high
gauge_3.png 2.5 2.4 (-0.1) high 2.4 (-0.1) high
gauge_4.png 3.7 3.8 (+0.1) high 3.6 (-0.1) high
gauge_5.png 4.4 4.4 (+0.0) high 4.6 (+0.2) high
gauge_6.png 6.1 6.4 (+0.3) high 6.4 (+0.3) high
gauge_7.png 7.9 7.8 (-0.1) high 8.2 (+0.3) high
gauge_8.png 9.3 8.8 (-0.5) high 9.4 (+0.1) high <-- ask a person
gemma-4-26b: mean error 0.25 bar, worst 0.6 bar, 4 of 8 within 0.2 bar
qwen3.6-35b: mean error 0.56 bar, worst 3.4 bar, 5 of 8 within 0.2 bar
We ran the program six times, and the readings hardly moved. Gemma read gauge 2 as 2.0 once, 2.1 in the other five runs. Qwen read gauge 8 as 9.6 once, 9.4 in the other five. Everything else was the same every time.
What the models got wrong
- Most readings are close. Six gauges out of eight are within 0.3 bar for both models. A small mark is 0.2 bar: the models are often one mark off, rarely more.
- The big miss: gauge 1. The needle is at 0.6 bar, near the bottom left. Qwen read 4.0, six runs out of six: an error of 3.4 bar. Gemma read 1.2, twice the true value.
- In our gauges, the ends of the scale were the hardest. Gauge 8 (9.3 bar) is Gemma's other bad reading: 8.8. For a boiler, the end of the scale is exactly where the problems are.
- The confidence is always “high”. In every answer of every run, for both models, including 4.0 for 0.6. A model's confidence about its own reading is a word it writes, not a measure. Do not decide anything with it.
A model knows only what you tell it
The sentence “The scale goes clockwise: 0 is at the bottom left, 5 at the top, 10 at the bottom right” was not in our first version. Without it, three runs out of three, Gemma read gauge 1 as 8.2 and gauge 8 as 8.6. With it, 1.2 and 8.8. Qwen read 4.0 for gauge 1 with and without the sentence.
A person sees the scale at a glance. A model may not: describe what you know about the picture, and measure again.
Two readers, and what they cannot see
In the boiler room there is no truth.json. So the program makes a check that works without it: if the two models disagree by more than 0.5 bar, or a reading is off the scale, it asks a person.
In our runs this flagged gauge 1 and gauge 8: Gemma's two worst readings and Qwen's worst. It did not flag gauge 6, where both models read 6.4 for 6.1. Two readers that agree can still both be wrong: the check lowers the risk, it does not remove it.
Why confidence comes first in the schema
In our first version value came first. Gemma wrote the number, then blank lines, again and again, until max_tokens stopped it: finish_reason was length, and the JSON was not complete. The very first run stopped at gauge 1. With confidence first, it never happened in 88 calls to Gemma. The finish_reason check is still in the program: when it happens, you want a clear error, not a broken number.
Good to know
- Real photos are harder. These pictures are clean and straight. A camera in a boiler room sees glass, glare, a tilted dial and poor light. Measure again with your own photos.
- For one gauge and a fixed camera, plain image processing is often better. A short OpenCV program that finds the angle of the needle gives the same answer every time and cost nothing per reading. A model helps when there are many different gauges, or as a second reader.
- A reading is cheap. One of these pictures was about 300 prompt tokens for Gemma. One photo an hour is 24 calls a day.
- Keep the photo. Save it next to every reading: when someone asks “was it really 9 bar on Sunday?”, you can look.
Exercise
- Delete the sentence about the scale and run the program again. What does each model read for gauge 1?
- Draw a thermometer dial: −20 to 60 °C, a mark every degree. Change
angle(), the numbers on the scale and the prompt. Is a model better at degrees than at bars? - Add four values next to the ends of the scale, like 0.2, 0.9, 9.0 and 9.8, and run the program three times. Are the ends really the hardest?
- Make it a job for a Raspberry Pi with a camera: one photo an hour, a message when the pressure is in the red zone, but only when both models agree; otherwise, ask a person to go and look.
