Как стать автором
Обновить

«Машинное чтение» цифровых и не только индикаторов без ИИ и нейронок на Python

Время на прочтение9 мин
Количество просмотров5.2K

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

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

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


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

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

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

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

Теги:
Хабы:
Всего голосов 15: ↑15 и ↓0+15
Комментарии14

Публикации

Истории

Работа

Data Scientist
75 вакансий
Python разработчик
130 вакансий

Ближайшие события

27 августа – 7 октября
Премия digital-кейсов «Проксима»
МоскваОнлайн
11 сентября
Митап по BigData от Честного ЗНАКа
Санкт-ПетербургОнлайн
14 сентября
Конференция Practical ML Conf
МоскваОнлайн
19 сентября
CDI Conf 2024
Москва
24 сентября
Конференция Fin.Bot 2024
МоскваОнлайн
25 сентября
Конференция Yandex Scale 2024
МоскваОнлайн
28 – 29 сентября
Конференция E-CODE
МоскваОнлайн
28 сентября – 5 октября
О! Хакатон
Онлайн
30 сентября – 1 октября
Конференция фронтенд-разработчиков FrontendConf 2024
МоскваОнлайн