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

Примеры индикаторов
Примеры индикаторов

Не будет машинного обучения, нейросетей, а для распознавания будем применять только библиотеки Python для работы с изображениями.

Задача

Требуется собрать и обработать показания приборов с цифровыми индикаторами. Для эксперимента будем использовать подручные бытовые датчики температуры, влажности и содержания CO².

Подготовим коллекцию изображений с нашей «лабораторной установки» любым приложением с функцией таймлапс. Интервал между снимками подбираем так, чтобы не пропустить изменение показателя в 2–5%.

«Установка» может выглядеть следующим образом. Размещаем в одном кадре несколько приборов для измерения показателей воздуха в помещении. В настройках таймлапс приложения укажем интервал 1 минута, длительность 2 часа, размера кадра 640×480px будет достаточно. После обработки изображений сведем данные в таблицу и оценим расхождения в показаниях приборов на графиках.

Итак приступим.

Определим следующие требования к обработчику изображений:

  1. распознаваться должны значения независимо от наклона камеры по отношению к индикатору;

  2. чтобы устранить этап предварительной обработки изображений такие характеристики, как контраст, цветность, яркость изображения не должны оказывать значительного влияния на распознавание;

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

Разметка изображения

Для предварительной разметки размещения индикаторов на снимке подойдет любой файл из коллекции таймлапс изображений.

Разметка индикаторных зон

Код для разметки датчиков на изображении:

# размечаем области с индикаторами


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 было потрачено время, но результат разочаровал.
Потом была такая переписка (узнаете картинки?) из которой собственно и появилось решение и эта статья.


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

Но эти задачи читатель м��жет попробовать реализовать самостоятельно.

Если статья показалась вам полезной, поставьте, пожалуйста, соответствующую отметку.
Вопросы и ваши кейсы приветствуются в комментариях.

С уважением и успехов!