A Raspberry Pi pipeline that reads seven-segment LCD gas meter displays from photos using a YOLOv8 object detector, reports readings to Home Assistant, and optionally submits them to Gas Networks Ireland.
A Pi 4 with Camera Module 3 photographs the meter every 10 minutes via cron. Each image is fed to a fine-tuned YOLOv8n model that:
- Detects every digit in the image — outputs a bounding box, class label (0–9), and confidence score for each
- Sorts detections left-to-right by bounding box x-centre to reconstruct the reading
- Reports the reading to Home Assistant as
sensor.gas_meter
No fixed ROI, no column segmentation, no preprocessing chain. One model forward pass produces the complete reading.
# Create a virtual environment and install deps
python -m venv venv
source venv/bin/activate
pip install -r requirements.txtA trained model ships with the repo (model_digit_yolo.onnx), along with a
calibrated roi_config.json, so you can read an image straight away:
python infer_yolo.py data/meter-captures/your_capture.jpeg --debuginfer_yolo.py uses model_digit_yolo.onnx by default; pass --model to use
your own weights. Note the bundled model and ROI are calibrated to my meter
and camera framing — for your own meter you'll want to retrain (below).
# Step 0: one-time calibration — draw a box around the display (opens a GUI window)
python calibrate.py
# Output: roi_config.json
# Step 1: label captured images with their meter readings
python label.py
# Output: label_progress.json
# Step 2: convert labels to YOLO dataset format
python prepare_yolo_dataset.py
# Output: yolo_dataset/ (2,584 train / 645 val images + annotations)
# Step 3: fine-tune YOLOv8n (~1 hour on a laptop GPU)
# downloads the COCO-pretrained yolov8n.pt base model on first run
python train_yolo.py
# Output: model_digit_yolo.pt
# Step 4: run inference with your freshly trained model
python infer_yolo.py data/meter-captures/your_capture.jpeg --debug
calibrate.pyandlabel.pyopen matplotlib GUI windows, so run them on a machine with a display — not headless over SSH.
| Script | Purpose |
|---|---|
calibrate.py |
One-time GUI calibration → roi_config.json (used for labeling, not inference) |
extract.py |
Batch crop all images in data/meter-captures/ → data/extracted/ |
label.py |
Interactive labeling → label_progress.json |
prepare_yolo_dataset.py |
Convert label_progress.json → YOLO training format |
train_yolo.py |
Fine-tune YOLOv8n on the labeled dataset → model_digit_yolo.pt |
infer_yolo.py |
Production inference: detect digits, POST reading to Home Assistant |
submit_gni.py |
Submit a reading to Gas Networks Ireland |
sync_captures.sh |
Pull new captures from Pi via SCP |
YOLOv8n (nano) fine-tuned on 3,229 meter capture images:
Input image → 300px-padded ROI crop → YOLOv8n → [(digit, bbox, conf), ...]
↓ sort left-to-right
→ "10872.253 m³"
| Property | Value |
|---|---|
| Architecture | YOLOv8n (3.2M parameters) |
| Pre-training | COCO (80-class, 120K images) |
| Fine-tune dataset | 2,584 train / 645 val meter images |
| mAP50 (validation) | 0.9950 |
| Precision / Recall | 0.9995 / 1.0000 |
| Training model | model_digit_yolo.pt (~6 MB) |
| Deployment model | model_digit_yolo.onnx (~12 MB, no PyTorch needed) |
| Inference time (Pi 4 CPU) | ~300–500ms via ONNX Runtime |
Training uses transfer learning from COCO pre-trained weights — near-perfect accuracy in a few epochs on a consumer GPU. The model is exported to ONNX for Pi deployment to avoid PyTorch ARM compatibility issues.
An early approach used a CNN to classify pre-cropped 28×28 digit patches. This required:
- Exact pixel-level calibration of digit column boundaries
- Per-crop preprocessing (CLAHE, min-max stretch, polarity normalisation)
- Re-calibration and re-training every time the camera shifted position
The YOLO approach eliminates all of these fragilities. The model finds digits wherever they appear in the image. Camera drift of up to 300px is tolerated automatically because training crops include 300px padding around the display.
# 1. Sync new captures from Pi
bash sync_captures.sh
# 2. Extract display crops from new captures
python extract.py
# 3. Label new captures interactively
python label.py
# Shows each display crop; type the 8-digit reading shown
# 4. Regenerate YOLO dataset and retrain
python prepare_yolo_dataset.py
python train_yolo.py
# 5. Deploy updated model to Pi
scp model_digit_yolo.pt pi@raspberrypi.local:/home/pi/meter-ocr/data/
meter-captures/ raw Pi captures (.jpeg files, not in git)
extracted/ display crops (output of extract.py)
test/ held-out images with known readings
label_progress.json resume-safe map of image filename → 8-digit reading
yolo_dataset/ generated YOLO training data (not in git)
images/train/
images/val/
labels/train/
labels/val/
dataset.yaml
See DEPLOYMENT.md for the full Pi setup. The short version:
# Dev machine: export model to ONNX after training
venv/bin/python -c "from ultralytics import YOLO; YOLO('model_digit_yolo.pt').export(format='onnx', imgsz=640, simplify=True)"
# Copy files to Pi
scp infer_yolo.py model_digit_yolo.onnx roi_config.json \
pi@raspberrypi.local:/home/pi/meter-ocr/
# On the Pi: install runtime (no PyTorch needed)
pip3 install ultralytics onnxruntime opencv-python-headless python-dotenv --break-system-packages
# Run inference on Pi using ONNX model
python3 /home/pi/meter-ocr/infer_yolo.py \
--model /home/pi/meter-ocr/model_digit_yolo.onnx \
/home/pi/meter-captures/20260601_152844.jpegThe pipeline creates sensor.gas_meter after the first successful reading. Add it as a gas source under Settings → Energy → Gas consumption to track usage in the Energy dashboard.
| Attribute | Value |
|---|---|
state |
reading in m³ (e.g. 10872.253) |
unit_of_measurement |
m³ |
device_class |
gas |
state_class |
total_increasing |
confidence |
mean digit confidence (0–1) |
Gas Networks Ireland accept readings via their online form. submit_gni.py automates the submission. Your GPRN is on your gas bill; keep it in .env as GNI_GPRN rather than passing it on the command line.
# Dry run — scrape the form + print the payload, do NOT submit
python submit_gni.py --dry-run 10883
# Real submit (GPRN read from .env)
python submit_gni.py 10883.253 # decimal dropped → 10883
python submit_gni.py --force 10883 # ignore the local cooldown checkThe form is a Drupal webform that POSTs to https://www.gasnetworks.ie/gas-meters/meter-reading:
gprn=<your-gprn>&meter_reading=10883&op=Submit+a+reading&form_id=webform_submission_submit_meter_reading_node_1300_add_form&form_build_id=<one-time token>
Key things the script handles:
form_build_idchanges on every page load and is tied to a session cookie. So submitting is a two-step round-trip: GET the page → scrape the freshform_build_idout of the meter-reading form specifically (the page hosts three webforms) → POST it back reusing the same session cookies.- No CAPTCHA on the meter-reading form. The
captcha_*/ reCAPTCHA fields on that page belong to a separate "request support" form, not this one. - Whole m³ digits only.
meter_readinghaspattern="^\d+$"— the integer part of the reading, no decimal. The OCR produces10883.253; the script submits10883. - A browser User-Agent is required — default script UAs get a 403.
GNI only accepts one reading per GPRN every 14 days. Submitting too soon is rejected with: "Sorry, you have recently submitted a meter reading for GPRN number … on <date>. You will need to wait an additional 14 days …".
The script tracks submissions in gni_submissions.json (gitignored — it holds
your GPRN) and:
- Refuses before hitting the network if you're still inside the window.
- Parses the date out of GNI's rejection message and records it, so local state stays in sync even with readings you submitted by hand.
--forcebypasses the local check (GNI will still reject if it's genuinely too soon).