Skip to content
Fondazione ITS INCOM

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:

  1. two vision models read every gauge, as JSON: a value and a confidence;
  2. your code compares each reading with the truth, and prints the error;
  3. 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

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

Exercise

All examples