В этой статье рассмотрим простой алгоритм считывания, обработки и распознавания значений цифровых индикаторов из массива фотографий с показаниями приборов.

Не будет машинного обучения, нейросетей, а для распознавания будем применять только библиотеки Python для работы с изображениями.
Задача
Требуется собрать и обработать показания приборов с цифровыми индикаторами. Для эксперимента будем использовать подручные бытовые датчики температуры, влажности и содержания CO².
Подготовим коллекцию изображений с нашей «лабораторной установки» любым приложением с функцией таймлапс. Интервал между снимками подбираем так, чтобы не пропустить изменение показателя в 2–5%.
«Установка» может выглядеть следующим образом. Размещаем в одном кадре несколько приборов для измерения показателей воздуха в помещении. В настройках таймлапс приложения укажем интервал 1 минута, длительность 2 часа, размера кадра 640×480px будет достаточно. После обработки изображений сведем данные в таблицу и оценим расхождения в показаниях приборов на графиках.

Итак приступим.
Определим следующие требования к обработчику изображений:
распознаваться должны значения независимо от наклона камеры по отношению к индикатору;
чтобы устранить этап предварительной обработки изображений такие характеристики, как контраст, цветность, яркость изображения не должны оказывать значительного влияния на распознавание;
должна быть возможность простой настройки для выборки зон на изображении, где расположены индикаторы и зон элементов числового индикатора.
Разметка изображения
Для предварительной разметки размещения индикаторов на снимке подойдет любой файл из коллекции таймлапс изображений.

Разметка индикаторных зон
Код для разметки датчиков на изображении:
# размечаем области с индикаторами
import glob
import matplotlib.pyplot as plt
import matplotlib.image as mpimg
from matplotlib.patches import Rectangle
from scipy import ndimage
# [id, top, bottom, width, height, rotation, description]
indicator_areas = [
[0, 176, 134, 22, 14, 0, "фон"],
[1, 216, 134, 81, 29, 0, "1_CO2"],
[2, 234, 169, 18, 10, 0, "1_Temp"],
[3, 277, 167, 20, 12, 0, "1_Hum"],
[4, 240, 212, 38, 35, -3, "2_Temp"],
[5, 283, 211, 24, 20, -4, "2_Hum"],
[6, 418, 106, 19, 12, 0, "3_Temp"],
[7, 418, 115, 19, 19, -5, "3_Hum"],
[8, 413, 130, 19, 19, -3, "3_CO2"],
[9, 390, 293, 31, 25, -5, "4_Temp"],
[10, 393, 328, 31, 25, -5, "4_Hum"],
]
_fmask = r"src/20240131/2024.01.31_11.14.27/*.jpg" # расположение снимков
image_files = glob.glob( _fmask , recursive=True )
fig, ax = plt.subplots(1, 1, figsize=(10, 5))
_img = mpimg.imread(image_files[1]) # для разметки берем любой файл
_img = ndimage.rotate(_img, 90, reshape=True ) # ориентируем изображение
for ii, s in enumerate(indicator_areas):
id, x, y, w, h, a, n = s
ax.add_patch(
Rectangle((x, y), w, h, edgecolor="#f00", facecolor="blue", fill=False, lw=1)
)
ax.imshow(_img)Немного придется поколдовать над настройкой областей indicator_areas, но в итоге получаем следующий результат:

Контроль разметки
Чтобы убедиться, что зоны определены корректно, соберем изображения индикаторов в одну таблицу.
# отрисовка значений индикторов в сводной таблице
import glob
import matplotlib.pyplot as plt
import matplotlib.image as mpimg
import scipy.ndimage as ndimage
images = []
image_files = glob.glob(r'src\20240131\2024.01.31_11.14.27\*.jpg')
for f in image_files[:5]:
_img = mpimg.imread(f)
_img = ndimage.rotate(_img, 90, reshape=True)
images.append(_img)
# [id, top, bottom, left, rigth, rotation, desctiption]
indicator_areas = [
[0,176,134,22,14,0, "фон"],
[1,216,134,81,29,0, "1_CO2"],
[2,234,169,18,10,0, "1_Temp"],
[3,277,167,20,12,0, "1_Hum"],
[4,240,212,38,35,-3, "2_Temp"],
[5,283,211,24,20,-4, "2_Hum"],
[6,418,106,19,12,0,"3_Temp"],
[7,418,115,19,19,-5,"3_Hum"],
[8,413,130,19,19,-3,"3_CO2"],
[9,390,293,31,25,-5,"4_Temp"],
[10,393,328,31,25,-5,"4_Hum"],
]
n_cols = len(indicator_areas)
n_img = len(images)
fig, axs = plt.subplots(nrows=len(images), ncols=n_cols+1, figsize=(8, int(n_img/1.5)))
plt.setp(plt.gcf().get_axes(), xticks=[], yticks=[]);
plt.subplots_adjust( top=0.7,bottom=0, wspace=0, hspace=0.5)
for i, image in enumerate(images):
for ii, ia in enumerate(indicator_areas):
n,x,y,w,h,a,n = ia # ID, x,y, width, height, rotation angle
_image = image[y:y+h,x:x+w] # 👍 Numpy за такую возможность выборки областей
if a != 0:
_image = ndimage.rotate(_image, a, reshape=True)
_image = _image[6:-3,1:-2] # немного подрезаем области с поворотом
# вывод комментария для контроля, что разметка выполна верно
axs[i,ii].text(0,-2, n, **{"color":'#f00', "size":9 })
if(ii == 0 ): axs[i,ii].text(10,8, i, color='#ff0')
axs[i,ii].imshow(_image)
fig.savefig(f'co_2_{n_img}.png',bbox_inches='tight')Получим следующее изображение для 5 файлов:

На этом этапе, если значений немного, то можно их перенести вручную в таблицу для построения сравнительных графиков и оценки отклонений. Т.к. значения теперь рядом процесс перепечатывания упростится и ускорится, да и количество ошибок при вводе показаний сократится.
На основе таблицы можно построить графики и оценить корректировки по каждому из приборов, в частности, для индикаторов CO² получилась следующая картина.

С частичной автоматизацией ручного ввода разобрались. Теперь попробуем распознать значения индикаторов по изображениям.
Распознавание цифр на индикаторе
Задумка в следующем, т.к. цифровой индикатор состоит из 71 сегментов для отображения значений будем определять отличается ли изображение сегмента от области индикатора, где сегменты отсутствуют, т. е. фона.
1 сегментов может быть 8 или 9, если учитывать точку на индикаторах с поддержкой десятичных дробей или в экзотических случаях значков для отображения +/‑
Предварительно подбираем области индикаторных элементов и визуально проверяем наложение по 10 изображениям для каждой из цифр.
# Проверяем на отдельных цифрах
import glob
import pandas as pd
import numpy as df
from PIL import Image, ImageChops
from matplotlib.patches import Rectangle
# функция для определения отличается ли область сегмента индикатора от фона,
# подойдет любой другой вариант, чтобы понять активен сегмент или нет
def calcdiff(im1, im2):
dif = ImageChops.difference(im1, im2)
return np.mean(np.array(dif))
# этот колбэк не имеет практического заначения, используется только для красоты
def make_pretty(styler):
styler.background_gradient(axis=None, vmin=0, vmax=100, cmap="Blues")
return styler
# готовим изображения индикаторов для каждой цтфры
img_samples = glob.glob(r'numsample\src\nums\*.png', recursive=True)
# задаем области сегментов индикатора
segs = [
[8,5,4,5], # фон индикатора
[1,6,4,4], # сегмент 1
[9,0,4,5], # сегмент 2
[14,6,4,4],
[13,16,4,4],
[7,22,4,5],
[1,16,4,4],
[8,11,4,5], # сегмент N
]
fig, axs = plt.subplots(ncols=len(img_samples), nrows=1, figsize=(15,4))
plt.setp(plt.gcf().get_axes(), xticks=[], yticks=[]);
plt.axis('off');
x,y,w,h = segs[0]
img_empty = Image.open(img_samples[0])
img_empty = np.array(img_empty)[y:y+h, x:x+w]
plt.imshow(img_empty)
num_segs_array = []
num_signature = {}
for i,im in enumerate(img_samples):
image_2=Image.open(im)
num_segs = []
img_num = Image.open(img_nums[i])
for ii, s in enumerate(segs):
x,y,w,h = s
axs[i].add_patch (Rectangle((x,y), w, h, edgecolor = '#f00', facecolor = 'blue', fill= False , lw= 1 ))
axs[i].text (x+1,y+3, ii, **{"color":"yellow"} )
if ii > 0:
im_cmp_1 = Image.fromarray(img_empty, 'RGB')
im_cmp_2 = Image.fromarray(np.array(img_num)[y:y+h, x:x+w])
num_segs += [int(f'{calcdiff(im_cmp_1, im_cmp_2):.0f}') ]
num_segs_array += [num_segs ]
# здесь можно задать уровень, по которому определяется активен сегмент или нет
num_signature["".join(['1' if int(n) > 50 else '0' for n in num_segs])] = i
axs[i].imshow(img_num)
df = pd.DataFrame(data=num_segs_array)
# выводим результат авторматической маркировки, собственно, эти сигнатуры можно было
# бы и просто описать указав 1 для активного сегмента, а 0 для не активного )
print (num_signature)
# визуализация сегментов индикатора (столбцы) для разных цифр (строки)
df.style.pipe(make_pretty) Зоны (segs) на индикаторе визуализируем для подбора оптимальных позиции и размеров областей сегментов.

В таблице уровни различий от фона для зон сегментов индикатора, определяемые ImageChops.difference(im1, im2). В нашем случае уровень от 0 до 100 не имеет значения, сигнатура для цифры определяется порогом в 50 и описывается 0 и 1, что определяет активен сегмент или нет для конкретной цифры.

Сигнатура для определения цифр выглядит следующим образом:
num_signature = {'1111110': 0,
'0011000': 1,
'0110111': 2,
'0111101': 3,
'1011001': 4,
'1101101': 5,
'1101111': 6,
'0111000': 7,
'1111111': 8,
'1111101': 9}Мы могли бы сигнатуру описать и без таблицы со уровнем отличия (от 0 до 100) сегментов от фона, но таблица дает определенную уверенность (порог между значениями 0/1 достаточно высокий), что распознавание будет надежным.
Проверка
Предварительно для тестирования потребуется нарезать в отдельную директорию большой массив изображений индикаторов, чтобы на случайной выборке проверять наш алгоритм.
# проверяем насколько у нас хорошо получилось на случайных изображениях
# функция для случайной выборки изображений индикатора
import random
def randImgs(sample_size=10, _mask=r"E:\tmp\со2\20240131\2024.01.31_11.14.27\*.jpg"):
glob_files = glob.glob(_mask, recursive=True)
sample_size = sample_size if sample_size <= len(glob_files) else len(glob_files)
_sample = [
glob_files[i]
for i in sorted(random.sample(range(len(glob_files)), sample_size))
]
return _sample
segs = [
[8, 5, 4, 5], # фон индикатора
[1, 6, 4, 4], # сегмент 1
[9, 0, 4, 5], # сегмент 2
[14, 6, 4, 4],
[13, 16, 4, 4],
[7, 22, 4, 5],
[1, 16, 4, 4],
[8, 11, 4, 5], # сегмент N
]
num_signature = {
"1111110": 0,
"0011000": 1,
"0110111": 2,
"0111101": 3,
"1011001": 4,
"1101101": 5,
"1101111": 6,
"0111000": 7,
"1111111": 8,
"1111101": 9,
}
def recoNum(img_path, segs):
x, y, w, h = segs[0]
_img = Image.open(img_path)
img_empty = np.array(_img)[y : y + h, x : x + w]
num_segs = []
for ii, s in enumerate(segs):
x, y, w, h = s
if ii > 0:
im_cmp_1 = Image.fromarray(img_empty, "RGB")
im_cmp_2 = Image.fromarray(np.array(_img)[y : y + h, x : x + w])
num_segs += [int(f"{calcdiff(im_cmp_1, im_cmp_2):.0f}")]
sig = "".join(["1" if int(n) > 50 else "0" for n in num_segs])
res = (
num_signature[sig] if sig in num_signature else "x"
) # x - если значения не найдено в num_signature
return res
sample_size = 180
colnum = 20
img_samples = randImgs(sample_size=sample_size, _mask=r"numsample\src\*.png")
results = []
fig, axs = plt.subplots(nrows=int(sample_size / colnum), ncols=colnum, figsize=(10, 8))
plt.setp(plt.gcf().get_axes(), xticks=[], yticks=[])
for i, img_path in enumerate(img_samples):
_n = str(recoNum(img_path, segs))
r = int(i / colnum)
c = i % colnum
axs[r, c].text(6, -1, _n, **{"size": 10, "color": "#f0f"})
axs[r, c].imshow(Image.open(img_path))
results += [_n]В итоге получаем, что если изображения индикатора более менее соответствует образцовому изображению, то распознавание выполняется практически со 100% вероятностью. Для неполного изображения индикатора, камера или прибор поменяли положение во время съемки, то как правило, значение не определяется. В некоторых случаях возможны и ложные срабатывания (выделено красной рамкой) :

В случае с 8 понятно почему возникла ошибка, и чтобы ее исправить можно немного подумать и добавить одну строку в коде
Такой способ определения цифр по сегментам достаточно всеядный, т.к. определение активности сегментов индикатора выполняется путем сравнения с фоном, то есть контрастность, цвет, глубина цветовой палитры файла не оказывают существенного влияния.
Для распознавания по стандартной разметке потребуется преобразовать (поворот, масштабирование, наклон, перспектива) исходное изображение индикатора к более менее стандартному виду:

Но возможен вариант и без трансформации изображения. Для него потребуется разметку сегментов переопределить.
В первом и втором случаях преобразованы изображения под стандартную разметку сегментов, в третьем и четвертом задана индивидуальная разметка.

Заключение
Алгоритм может быть дополнен распознаванием значений для индикаторов с десятичным разделителем, или использоваться для других датчиков и даже аналоговых приборов. Например, достаточно будет добавить соответствующие зоны на шкале и проверять изменение цвета сегментов для распознавания значений индикатора:


Совершенствование обработки изображений может быть продолжено с использованием библиотек автоматического определения местоположения индикаторов на снимке. Но это уже специальные алгоритмы, а в этой статье мы обходимся без ML, причем, вполне осознано. На эксперименты с tesseract было потрачено время, но результат разочаровал.
Потом была такая переписка (узнаете картинки?) из которой собственно и появилось решение и эта статья.

Возможно, стоит проверить, как будет работать алгоритм для цифр отображаемых шрифтами, если креативно задать области для сравнения, то можно получить вполне приемлемую достоверность распознавания.
Но эти задачи читатель м��жет попробовать реализовать самостоятельно.
Если статья показалась вам полезной, поставьте, пожалуйста, соответствующую отметку.
Вопросы и ваши кейсы приветствуются в комментариях.
С уважением и успехов!
