Главная
Блог разработчиков phpBB
 
+ 17 предустановленных модов
+ SEO-оптимизация форума
+ авторизация через соц. сети
+ защита от спама

Съёмка показаний счетчика на телефон с дальнейшим распознаванием

Anna | 15.06.2014 | нет комментариев

Введение

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

Допустимо, так ведут себя не все котлы, но наш отключается даже при кратковременном перебое с подачей газа и не включается обратно, если подача восстановится. Если кто-то есть дома, то это не задача, нажал кнопку и котел греет дальше, но если внезапно так сложилось, что мы решили каждой семьей поехать в отпуск, а на дворе зима, отличная такая, чтоб -20°C, то итоги могут быть печальными.

Решение примитивное — оставить ключи родственникам/друзьям/соседям, Дабы они могли приехать и включить котел, случись какая-нибудь неприятность. Отлично, если есть сосед, тот, что будет всякий день заходить и проверять, всё ли в порядке. А если нет? Либо он тоже решит уехать куда-нибудь на выходные?

Выходит, я решил наладить выкладывание показаний счётчика куда-нибудь в Интернет, чтоб я мог находясь где-нибудь в далекой поездке периодично проверять, расходуется ли газ, а если внезапно перестанет расходоваться, то неотложно звонить родственникам/друзьям/соседям (либо кому там я оставил ключи), Дабы приехали и нажали кнопку.

Безусловно, позже простого выкладывания показаний в Интернет я решил не останавливаться на достигнутом и замутил ещё идентификация показаний и графическое представление, об этом читайте в части 2 данного топика.

Часть 1. Снятие показаний со счетчика и выкладывание их в Интернет

Тут нужно оговориться, что счётчики бывают в природе абсолютно различные, некоторые из них имеют особые шины и интерфейсы для автоматизированного съема показаний. Если у Вас такой, то дальше, вероятно, дозволено не читать. Но у меня самый обыкновенный без сходственных интерфейсов (по крайней мере, я не нашёл, может, нехорошо искал), модель GALLUS iV PSC. Следственно остается один вариант — визуальный съём показаний. В сети предлагают готовые решения, но они стоят немалых денег, а основное, это вовсе не спортивно, следственно будем делать всё сами.

Что нам потребоваться?

Для снятия показаний со счетчика с дальнейшей отправкой этих показаний в интернет нам потребуется всякий непотребный android телефон. Я, скажем, применял для этих целей Samsung Galaxy S III (SCH-I535). Да, вероятно, не у всякого читателя есть валяющийся без дела с-3-й галакси, но необходимо понимать, что требования к телефону не так уж и огромны:

  • он должен загружаться
  • должна трудиться камера
  • должен трудиться WiFi

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

Имея хобби приобретать на ebay различные битые телефоны и собирать из них работающие, я легко обнаружил у себя в загашнике материнскую плату от sgs3 с неработающим микрофоном (~$10), а также б/у-шную камеру (~$10) и китайскую батарейку (~300р). Также для комфорта крепления батарейки к плате применял фрейм с битым дисплеем.

Вначале думал обойтись только материнской платой и камерой, но оказалось, что даже при подключении к зарядке плата не включается без батарейки, следственно пришлось ещё добавить фрейм и батарейку. Но и в этом случае бюджет получился порядка $30, если применять агрегаты примитивней sgs3, то дозволено уложиться и в меньшую сумму.

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

Настройка агрегата

Будем исходить из наихудшего сценария. Представим, что нет ни дисплея, ни тачскрина, на телефоне отсутствует root, adb отладка отключена, прошивка незнакома.

Реанимация

Внимание! Последующая инструкция подходит для агрегата Samsung Galaxy S III (SCH-I535), если у Вас иной телефон, то действия могут отличаться.

Предполагается, что Вы отлично знакомы с такими представлениями как adb, прошивки и пр.

Дабы привести телефон в больше-менее знаменитое нам состояние для начала прошьем стоковую прошивку VRBMB1 отсель применяя Odin. Не буду детально описывать, как это делается, в Интернете полно инструкций, как пользоваться Odin-ом. Odin в нашем случае отличен тем, что с ним легко трудиться не применяя экран телефона, необходимо только включить телефон в режиме загрузки (Vol Down Home Power — подержать несколько секунд, после этого Vol Up, подключить по usb к винде и всё, дальше дело Odin-а).

Позже того, как Odin прошьет сток, телефон загрузит систему, отключаем его от usb и вынимаем батарейку, Дабы он выключился. Эту операцию необходимо делать всякий раз позже заключения прошивки Odin-ом, Дабы начинать следующую операцию с отключенного состояния.

Дальше шьем CWM recovery и root по инструкции. Если лаконично, то так:

  • Через Odin прошиваем кастомный бутчейн VRALEC.bootchain.tar.md5
  • Через Odin прошиваем CWM recovery
  • Через CWM recovery прошиваем SuperSU_Bootloader_FIXED.zip. В инструкции написано, что zip необходимо закинуть на sd-карту, но ввиду отсутствия экрана проще это сделать через sideload:
    Включаем тело зажав Vol Up Home Power — удерживаем несколько секунд, потом еще секунд 5 загрузка, попадаем в режим CWM-recovery.
    Проверяем это, набрав в консоли в ubuntu adb devices (тело, само собой должно быть подключено по usb и должен быть установлен adb — sudo apt-get install android-tools-adb):

    malefic@lepeshka:~$ adb devices 
    List of devices attached 
    64cb5c59	recovery 
    

    Если видим последнюю строчку, значит все в порядке, жмем на девайсе Vol Down, Vol Down, Power — переходим в режим adb sideload (по крайней мере в версии CWM из инструкции это вторая строчка сверху), остается только набрать в консоли ubuntu:

    malefic@lepeshka:~$ adb sideload SuperSU_Bootloader_FIXED.zip 
    sending: 'sideload'  100%  
    

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

  • Через Odin прошиваем стоковый бутчейн, соответствующий поставленной до этого стоковой прошивкеVRBMB1_Bootchain.tar.md5

Дальше нам необходимо включить usb-отладку на телефоне, для этого запускаем телефон в режим CWM-recovery, проверяем:

malefic@lepeshka:~$ adb devices 
List of devices attached 
64cb5c59	recovery 

Монтируем system:

malefic@lepeshka:~$ adb shell mount -o rw -t ext4 /dev/block/platform/msm_sdcc.1/by-name/system /system

Добавляем строчку в /system/build.prop:

malefic@lepeshka:~$ adb shell "echo "persist.service.adb.enable=1" >> /system/build.prop"

Перезагружаем:

malefic@lepeshka:~$ adb reboot

Ожидаем загрузки, проверяем в терминале ранг adb:

malefic@lepeshka:~$ adb devices 
List of devices attached 
64cb5c59	device 

Бинго! Отладка включена, давайте посмотрим, что там у нас творится на телефоне, для этого запускаемAndroidScreenCast с поддержкой Java Web Start и видим:

Это экран активизации симкарты Verizon, у меня такой симки нет, следственно я легко пропускаю активизацию, действуя по инструкции:

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

А именно:

malefic@lepeshka:~$ adb shell input tap 10 1150
malefic@lepeshka:~$ adb shell input tap 710 1150
malefic@lepeshka:~$ adb shell input tap 10 1150
malefic@lepeshka:~$ adb shell input tap 710 1150

после этого нажимаю ьзовал первое попавшееся приложение — FolderSync Lite. Оно может синхронизировать папку на телефоне с папкой, скажем, на Google диске.

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

Часть 2. Идентификация показаний

Выходит, позже отправки показаний счётчика в Интернет, меня заинтересовала вероятность механического распознавания показаний. Это дозволит:

  • проводить статистический обзор потребления газа
  • механически отслеживать перебои с подачей газа (с вероятностью предупреждения по e-mail либо смс)

В качестве языка разработки был выбран python, для работы с изображениями применялась библиотекаOpenCV.

Вот код стержневой программы, которая запускается по крону раз в час:

import sys
import os

from models import getImage, sess
from gdrive import getImagesFromGDrive, createImageFromGDriveObject

if __name__ == '__main__':

    # получаем список новых фото с гугл диска
    images, http = getImagesFromGDrive()

    # поочередно обрабатываем их в цикле
    for img_info in images:

        # скачиваем изображение
        img = createImageFromGDriveObject (img_info, http)
        file_name = img_info['title']

        # ищем запись в базе
        try:    
            dbimage = getImage(os.path.basename(file_name))
            dbimage.img = img
            dbimage.download_url = img_info["downloadUrl"]
            dbimage.img_link = img_info['webContentLink'].replace('&export=download','')
        except ValueError as e:
            print e
            continue

        # распознаем показания
        dbimage.identifyDigits()

        # сберегаем данные в базу
        sess.commit()

Тут применяются функции, код которых я выложу ниже:

  • getImagesFromGDrive — функция, возвращающая список ещё не распознанных изображений с Google Диска
  • createImageFromGDriveObject — функция, скачивающая само изображение и преобразующая его в формат OpenCV
  • getImage — функция ищет запись об изображении в базе данных, если таковой нет, то создает её
  • identifyDigits — способ, распознающий показания на данном изображении
  • http — авторизованный заказчик для доступа к Google Диску, детально про доступ к API Диска читаемтут
  • sess — объект подключения к базе данных, применяется библиотека SQL Alchemy
Работа с Google Диском

Первое, что мы делаем, это получаем с Google Диска список изображений:

import os

from datetime import tzinfo, timedelta, date
from dateutil.relativedelta import relativedelta

from apiclient.discovery import build

from models import getLastRecognizedImage

def getImagesFromGDrive():

    # определяем id папки Google Диска, в которой лежат изображения 
    FOLDER_ID = '0B5mI3ROgk0mJcHJKTm95Ri1mbVU'

    # создаем объект авторизованного заказчика
    http = getAuthorizedHttp()

    # объект обслуживания Диска
    drive_service = build('drive', 'v2', http=http)

    # для начала удаляем с Диска все изображения старше месяца, они нам теснее не увлекательны
    month_ago = date.today()   relativedelta( months = -1 )
    q = "'%s' in parents and mimeType = 'image/jpeg' and trashed = false and modifiedDate<'%s'" % (FOLDER_ID, month_ago.isoformat()) 
    files = drive_service.files().list(q = q, maxResults=1000).execute()
    for image in files.get('items'): 
        drive_service.files().trash(fileId=image['id']).execute()

    # сейчас делаем запрос к базе, возвращающий последнее распознанное изображение
    last_image = getLastRecognizedImage()    

    # получаем с Диска список изображений, дата метаморфозы которых огромнее даты съемки последнего распознанного изображения
    page_size = 1000
    result = []
    pt = None
    # так как API Диска не разрешает за раз получить больше 1000 изображений, 
    # то скачиваем список постранично по 1000 штук и складываем в один массив
    while True:
        q = "'%s' in parents and trashed = false and mimeType = 'image/jpeg' and modifiedDate>'%s'" % (FOLDER_ID, last_image.check_time.replace(tzinfo=TZ()).isoformat('T'))
        files = drive_service.files().list(q = q, maxResults=page_size, pageToken=pt).execute()
        result.extend(files.get('items'))
        pt = files.get('nextPageToken')
        if not pt:
            break

    # переворачиваем список, Дабы обработка шла в порядке времени съемки
    result.reverse()

    return result, http

Авторизованный заказчик Диска создается дальнейшим образом:

import httplib2

import ConfigParser

from oauth2client.client import OAuth2WebServerFlow
from oauth2client.file import Storage

def getAuthorizedHttp():

    # достаем из файла config.ini записанные там CLIENT_ID и CLIENT_SECRET
    config = ConfigParser.ConfigParser()
    config.read([os.path.dirname(__file__) '/config.ini'])
    CLIENT_ID = config.get('gdrive','CLIENT_ID')
    CLIENT_SECRET = config.get('gdrive','CLIENT_SECRET')

    # OAuth 2.0 scope that will be authorized.
    # Check https://developers.google.com/drive/scopes for all available scopes.
    OAUTH_SCOPE = 'https://www.googleapis.com/auth/drive'

    # Redirect URI for installed apps
    REDIRECT_URI = 'urn:ietf:wg:oauth:2.0:oob'

    # в файле client_secrets.json будем беречь токен
    storage = Storage(os.path.dirname(__file__)   '/client_secrets.json')
    credentials = storage.get()

    # если в файле ничего нет, то запускаем процедуру авторизации
    if not credentials:
        # Perform OAuth2.0 authorization flow.
        flow = OAuth2WebServerFlow(CLIENT_ID, CLIENT_SECRET, OAUTH_SCOPE, REDIRECT_URI)
        authorize_url = flow.step1_get_authorize_url()
        # выводим в консоль ссылку, по которой нужно перейти для авторизации
        print 'Go to the following link in your browser: '   authorize_url
        # запрашиваем результат
        code = raw_input('Enter verification code: ').strip()
        credentials = flow.step2_exchange(code)
        # сберегаем токен 
        storage.put(credentials)

    # создаем http заказчик и авторизуем его 
    http = httplib2.Http()
    credentials.authorize(http)
    return http

Для приобретения CLIENT_ID и CLIENT_SECRET в Google Developers Console необходимо сделать план и для этого плана в разделе APIs & auth — Credentials — OAuth нажать CREATE NEW CLIENT ID, там предпочестьInstalled application — Other:

При первом запуске скрипт напишет в консоли url по которому необходимо перейти, Дабы получить токен, вставляем его в адресную строку браузера, разрешаем доступ приложения к Google Диску, копируем выданный гуглом верификационный код из браузера и отдаем скрипту. Позже этого скрипт сохранит все нужное в файл client_secrets.json и при последующих запусках не будет ничего спрашивать.

Функция скачивания изображения предельно примитивна:

import cv2
import numpy as np

def downloadImageFromGDrive (downloadUrl, http=None):
    if http==None:
        http = getAuthorizedHttp()
    # Скачиваем изображение
    resp, content = http.request(downloadUrl)
    # Создаем объект изображения OpenCV
    img_array = np.asarray(bytearray(content), dtype=np.uint8)
    return cv2.imdecode(img_array, cv2.IMREAD_COLOR)

def createImageFromGDriveObject (img_info, http=None):
    return downloadImageFromGDrive(img_info['downloadUrl'], http)
Поиск показаний на фото

Первое, что нужно сделать, позже того, как мы получили фото, это обнаружить на нём цифры, которые мы будем распознавать. Этим занимается способ extractDigitsFromImage:

    def extractDigitsFromImage (self):

        img = self.img

Первоначально фото выглядит вот так:

Следственно вначале мы его поворачиваем, Дабы оно купило надобную ориентацию.

        # вращаем на 90 градусов 
        h, w, k = img.shape
        M = cv2.getRotationMatrix2D((w/2,h/2),270,1)
        img = cv2.warpAffine(img,M,(w,h))

        # обрезаем черные поля, появившиеся позже вращения
        img = img[0:h, (w-h)/2:h (w-h)/2]
        h, w, k = img.shape

Сейчас разглядим ломтик изображения, обведённый красной рамкой. Он довольно уникален в пределах каждого фото, дозволено применять его для поиска циферблата. Я положил его в файл sample.jpg и написал дальнейший код для нахождения его координат:

# загружаем желанный ломтик фото из файла
        sample = cv2.imread(os.path.dirname(__file__) "/sample.jpg")
        sample_h, sample_w, sample_k = sample.shape

        # ищем наилучшее совпадение его с фото
        res = cv2.matchTemplate(img,sample,cv2.TM_CCORR_NORMED)
        min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(res)
        # вычисляем координаты центра наилучшего совпадения
        x_center = max_loc[0]   sample_w/2
        y_center = max_loc[1]   sample_h/2

        # данный маленький кусок кода обрезает левую часть фото, если обнаруженная точка оказалось слишком справа, 
        # Дабы циферблат оказался приблизительно по середине фото
        if x_center>w*0.6:
            img = img[0:h, 0.2*w:w]
            h, w, k = img.shape
            x_center = x_center-0.2*w

Точкой на рисунке обозначены обнаруженные координаты, то, что мы и хотели. Дальше запускаем алгорифм поиска границ, заранее переведя изображение в серые тона. 100 и 200 — значения пороговых значений, подобранные эмпирически.

        # переводим изображение в градации серого
        gray = cv2.cvtColor(img,cv2.COLOR_BGR2GRAY)
        # ищем границы алгорифмом Canny
        edges = cv2.Canny(gray, 100, 200)

Сейчас запускаем алгорифм поиска линий на полученном изображении с границами. Помимо самого изображения способ HoughLines также принимает в качестве параметров величины шагов поиска по расстоянию и углу поворота и пороговое значение отвечающее за минимальное кол-во точек, которые обязаны образовать линию. Чем поменьше данный порог, тем огромнее линий найдёт алгорифм.

        # находим прямые линии
        lines = cv2.HoughLines(edges, 1, np.pi/180, threshold=100)

Из всех обнаруженных линий рассматриваем только больше-менее горизонтальные и находим две особенно приближенные к найденному ранее центру (одну сверху, иную снизу).

        # инициализируем нужные переменные
        rho_below = rho_above = np.sqrt(h*h w*w)
        line_above = None
        line_below = None
        for line in lines:
            rho,theta = line[0]
            sin = np.sin(theta)
            cos = np.cos(theta)

            # выбрасываем не горизонтальные линии
            if (sin<0.7):
                continue

            # вычисляем ро для линии параллельной нынешней линии, но проходящей через "центральную" точку             
            rho_center = x_center*cos   y_center*sin

            # сопоставляем с ближайшей линией сверху
            if rho_center>rho and rho_center-rho<rho_above:
                rho_above = rho_center-rho
                line_above = {"rho":rho, "theta":theta, "sin":sin, "cos":cos}

            # сопоставляем с ближайшей линией снизу
            if rho_center<rho and rho-rho_center<rho_below:
                rho_below = rho-rho_center
                line_below = {"rho":rho, "theta":theta, "sin":sin, "cos":cos}

        # проверяем, обе ли линии удачно обнаружены 
        if line_below==None or line_above==None:
            mylogger.warn("No lines found")       
            return False 
        # проверяем, что обнаруженные линии находятся не дюже вдалеке друг от друга
        if rho_below/rho_above>1.7 or rho_below/rho_above<0.6:
            mylogger.warn("Wrong lines found: %f" % (rho_below/rho_above))
            return False

Поворчиваем изображение так, Дабы обнаруженные линии стали вовсе горизонтальными:

        # поворачиваем
        M = cv2.getRotationMatrix2D((0,(line_below["rho"]-line_above["rho"])/2 line_above["rho"]),line_above["theta"]/np.pi*180-90,1)
        img = cv2.warpAffine(img,M,(w,h))

Сейчас обрежем все, что находится за обнаруженными линиями:

        # обрезаем 
        img = img[line_above["rho"]:line_below["rho"], 0:w]
        h, w, k = img.shape

Дальше нам необходимо обнаружить левый и правый край циферблата, переводим изображение в черно-белое:

        # бинаризируем изображение 
        gray = cv2.cvtColor(img,cv2.COLOR_BGR2GRAY)
        thres = cv2.adaptiveThreshold(gray,255,cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 31, 2)

Правый край ищем по той же спецтехнологии, что и «центральную» точку, образец обведён красной рамкой:

        sample_right = cv2.imread(os.path.dirname(__file__) "/sample_right.jpg",cv2.IMREAD_GRAYSCALE)

        # определяем наилучшее совпадение с образцом
        res = cv2.matchTemplate(thres,sample_right,cv2.TM_CCORR_NORMED)
        min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(res)
        # вычисляем правую рубеж
        x_right = max_loc[0]-6

Для поиска левой границы применим реформирование закрытия для удаления шума:

        # удаляем шум
        kernel = np.ones((7,7),np.uint8)
        thres = cv2.morphologyEx(thres, cv2.MORPH_CLOSE, kernel)

Дальше будем перебирать все пиксели начиная с самого левого, пока не встретиться черный, это и будет левый край:

        # ищем левый край
        x_left=0
        while x_left<w :
            if thres[h/2,x_left]==0:
                break
            x_left =1

Обрежем изображение по левому и правому краю:

        # обрезаем слева и справа
        img = img[:, x_left:x_right]
        h, w, k = img.shape

Проведём небольшую проверку, что обнаруженное изображение по соотношению сторон соответствует циферблату:

        # проверяем соотношение сторон
        if float(w)/float(h)<6.5 or float(w)/float(h)>9.5:
            mylogger.warn("Image has bad ratio: %f" % (float(w)/float(h)))
            return False

        self.digits_img = img
        return True
Разбиение на цифры

Разбиением выделенного предыдущей функцией циферблата на отдельные цифры занимается способsplitDigits:

    def splitDigits (self):

        # проверяем, если циферблат ещё не выделен, то делаем это
        if None == self.digits_img:
            if not self.extractDigitsFromImage():
                return False

        img = self.digits_img
        h, w, k = img.shape

Для начала легко разрежем наш циферблат на 8 равных частей:

Обрабатывать будем только первые 7 частей, так как 8-я цифра непрерывно вертится, её напрасно распознавать.
Всякую часть переводим в ч/б цвет применяя способ adaptiveThreshold, параметры подобраны эмпирически:

        # разбиваем циферблат на 8 равных частей и обрабатываем всякую часть
        for i in range(1,8):
            digit = img[0:h, (i-1)*w/8:i*w/8]
            dh, dw, dk = digit.shape
            # переводим в ч/б
            digit_gray = cv2.cvtColor(digit,cv2.COLOR_BGR2GRAY)            
            digit_bin = cv2.adaptiveThreshold(digit_gray,255,cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 9, 0)

Немножко удаляем шум с поддержкой реформирования открытия (применяется ядро каждого 2х2). Без этого дозволено было бы и обойтись, но изредка это помогает отрезать от цифры крупные белые куски подсоединённые тонкими перемычками:

            # удаляем шум
            kernel = np.ones((2,2),np.uint8)
            digit_bin = cv2.morphologyEx(digit_bin, cv2.MORPH_OPEN, kernel)

Запускаем алгорифм поиска силуэтов

            # ищем силуэты
            other, contours, hierarhy = cv2.findContours(digit_bin.copy(),cv2.RETR_TREE,cv2.CHAIN_APPROX_SIMPLE)

Дальше выкинем все слишком маленькие силуэты и силуэты по краям изображения, потом найдём самый огромный силуэт из оставшихся:

            # анализируем силуэты
            biggest_contour = None
            biggest_contour_area = 0
            for cnt in contours:
                M = cv2.moments(cnt)

                # пропускаем силуэты со слишком крошечной площадью
                if cv2.contourArea(cnt)<30:
                    continue
                # пропускаем силуэты со слишком маленьким периметром
                if cv2.arcLength(cnt,True)<30:
                    continue

                # находим центр масс силуэта
                cx = M['m10']/M['m00']
                cy = M['m01']/M['m00']

                # пропускаем силуэт, если центр масс находится где-то с краю 
                if cx/dw<0.3 or cx/dw>0.7:
                    continue

                # находим крупнейший силуэт 
                if cv2.contourArea(cnt)>biggest_contour_area:
                    biggest_contour = cnt
                    biggest_contour_area = cv2.contourArea(cnt)
                    biggest_contour_cx = cx
                    biggest_contour_cy = cy

            # если не обнаружено ни одного подходящего силуэта, то помечаем цифру не распознанной
            if biggest_contour==None:
                digit = self.dbDigit(i, digit_bin)
                digit.markDigitForManualRecognize (use_for_training=False)
                mylogger.warn("Digit %d: no biggest contour found" % i)
                continue    

Самый огромный силуэт это и есть наша цифра, выкинем всё, что лежит за его пределами с поддержкой наложения маски:

            # убираем всё, что лежит за пределами самого большого силуэта 
            mask = np.zeros(digit_bin.shape,np.uint8)
            cv2.drawContours(mask,[biggest_contour],0,255,-1)
            digit_bin = cv2.bitwise_and(digit_bin,digit_bin,mask = mask)

Сейчас опишем вокруг всякой цифры прямоугольник стандартного размера с центром в центре масс силуэта:

            # задаем параметры описывающего прямоугольника
            rw = dw/2.0
            rh = dh/1.4

            # проверяем, Дабы прямоугольник не выходил за пределы изображения
            if biggest_contour_cy-rh/2 < 0:
                biggest_contour_cy = rh/2
            if biggest_contour_cx-rw/2 < 0:
                biggest_contour_cx = rw/2

Обрезаем изображение по прямоугольнику и масштабируем до заданного размера, у меня это digit_base_h = 24, digit_base_w = 16. Итог сберегаем в базу.

            # вырезаем прямоугольник 
            digit_bin = digit_bin[int(biggest_contour_cy-rh/2):int(biggest_contour_cy rh/2), int(biggest_contour_cx-rw/2):int(biggest_contour_cx rw/2)]

            # изменяем размер на типовой
            digit_bin = cv2.resize(digit_bin,(digit_base_w, digit_base_h))
            digit_bin = cv2.threshold(digit_bin, 128, 255, cv2.THRESH_BINARY)[1]

            # сберегаем в базу
            digit = self.dbDigit(i, digit_bin)

        return True

Идентификация цифр

Вот способ identifyDigits, тот, что вызывается из стержневой программы для всякого изображения:

    def identifyDigits(self):

        # если число теснее распознано, то ничего не делаем
        if self.result!='':
            return True

        # если цифры ещё не выделены
        if len(self.digits)==0:
            # если изображение не задано, то ничего не получится
            if self.img == None:            
                return False
            # выделяем цифры
            if not self.splitDigits():
                return False
            # утверждаем метаморфозы в базу, которые сделаны при выделении цифр
            sess.commit()

        # пытаемся распознать всякую цифру
        for digit in self.digits:
            digit.identifyDigit()

        # получаем текстовые значения цифр
        str_digits = map(str,self.digits)

        # если правда бы одна цифра не распознана, то показание также не может быть распознано
        if '?' in str_digits:
            return False

        # склеиваем все цифры для приобретения числа
        self.result = ''.join(str_digits)
        return True

Здесь все банально, помимо способа identifyDigit:

    def identifyDigit (self):

        # если цифра теснее распознана, то ничего не делаем
        if self.result!='?':
            return True

        if not KNN.recognize(self):
            # если не удалось распознать цифру, то помечаем её для ручной обработки
            self.markDigitForManualRecognize()
            # если это 7-я цифра, то считаем её равной "0", так как это последняя цифра и не критичная, а Зачастую бывает, что она не распознается
            if self.i==7:
                self.result = 0
                return True
            return False
        else:
            self.use_for_training = True

        return True

Способ identifyDigit тоже банален, идентификация происходит в способе KNN.recognize, применяется алгорифм поиска ближайших соседей из OpenCV:

    @staticmethod
    def recognize(dbdigit):
        # тренируем, если ещё не тренирован
        if not KNN._trained:
            KNN.train()

        # проверяем размер изображения, если не верный, то не пытаемся распознать
        h,w = dbdigit.body.shape
        if h!=digit_base_h or w!=digit_base_w:
            dbdigit.markDigitForManualRecognize(use_for_training=False)
            mylogger.warn("Digit %d has bad resolution: %d x %d" % (dbdigit.i,h,w))
            return False

        # преобразуем двумерное бинарное изображение в одномерный массив
        sample = dbdigit.body.reshape(digit_base_h*digit_base_w).astype(np.float32)
        test_data = np.array([sample])

        # запускаем способ определения ближайших соседей, кол-во соседей - 5
        knn = KNN.getKNN()
        ret,result,neighbours,dist = knn.find_nearest(test_data,k=5)

        # фильтруем видимо неверные итоги
        if result[0,0]!=neighbours[0,0]:
            # итог не равен особенно схожей цифре
            dbdigit.markDigitForManualRecognize()
            return False
        if neighbours[0,1]!=neighbours[0,0] or neighbours[0,2]!=neighbours[0,0]:
            # три особенно схожих цифры не равны между собой
            dbdigit.markDigitForManualRecognize()
            return False
        if dist[0,0]>3000000 or dist[0,1]>3500000 or dist[0,2]>4000000:
            # расхождения с тремя особенно схожими цифрами слишком крупные
            dbdigit.markDigitForManualRecognize()
            return False

        # если всё в порядке, то считаем идентификация благополучным и сберегаем итог
        dbdigit.result = str(int(ret))
        return True

Тренировка описана в способе KNN.train:

    @staticmethod
    def getKNN():
        # способ обеспечивает единстенную инициализацию объекта cv2.KNearest
        if KNN._knn==None:
            KNN._knn = cv2.KNearest()
        return KNN._knn
    @staticmethod
    def train():    
        knn = KNN.getKNN()
        # достаем из базы распознанные цифры для тренировки
        train_digits = sess.query(Digit).filter(Digit.result!='?').filter_by(use_for_training=True).all()
        train_data = []
        responses = []
        for dbdigit in train_digits:    
            h,w = dbdigit.body.shape
            # пропускаем цифры плохого размера
            if h*w != digit_base_h*digit_base_w:
                continue
            # преобразуем в одномерный массив
            sample = dbdigit.body.reshape(digit_base_h*digit_base_w).astype(np.float32)
            train_data.append(sample)
            responses.append(int(dbdigit.result))
        # тренируем KNN
        knn.train(np.array(train_data), np.array(responses))
        KNN._trained = True

Привожу выдержку из файла models.py, если у читателя остались вопросы по работе некоторых использованных, но не описанных функций.

Отсутствующие в статье изложения функций и способов

import datetime
from sqlalchemy import Column, Integer, String, Text, Boolean, ForeignKey, DateTime, PickleType
from sqlalchemy.orm import relationship
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker

import base64
import cv2
import numpy as np
import os
import logging
import sys

dbengine = create_engine('sqlite:///'   os.path.dirname(__file__)   '/../db/images.db', echo=False)

Session = sessionmaker(bind=dbengine)
sess = Session()

Base = declarative_base()

# image class
class Image(Base):
    __tablename__ = 'images'
    id = Column(Integer, primary_key=True)
    file_name = Column(String)
    img_link = Column(Text)
    download_url = Column(Text)
    check_time = Column(DateTime)
    result = Column(String(8))
    digits = relationship("Digit", backref="image")
    img = None # source image
    digits_img = None # cropped source image

    def __init__(self, file_name):
        self.file_name = file_name   
        self.check_time = datetime.datetime.strptime(file_name, "gaz.%Y-%m-%d.%H.%M.%S.jpg")
        self.result = ""

    def __repr__(self):
        return "<Image ('%s','%s','%s')>" % (self.id, self.file_name, self.result)

    def dbDigit(self, i, digit_img):
        digit = sess.query(Digit).filter_by(image_id=self.id).filter_by(i=i).first()
        if not digit:
            digit = Digit(self, i, digit_img)
            sess.add(digit)
        else:
            digit.body = digit_img
        return digit

    ## определенный код остутствует

# digit class
class Digit(Base):
    __tablename__ = 'digits'
    id = Column(Integer, primary_key=True)
    image_id = Column(Integer, ForeignKey("images.id"))
    i = Column(Integer)
    body = Column(PickleType)
    result = Column(String(1))
    use_for_training = Column(Boolean)

    def __init__(self, image, i, digit_img):
        self.image_id = image.id
        self.i = i
        self.body = digit_img
        self.markDigitForManualRecognize()

    def __repr__(self):
        return "%s" % self.result

    def markDigitForManualRecognize (self, use_for_training=False):  
self.result = '?'
        self.use_for_training = use_for_training        

    def getEncodedBody (self):
        enc = cv2.imencode('.png',self.body)[1]
        b64 = base64.b64encode(enc)
        return b64       

    ## определенный код остутствует

Base.metadata.create_all(bind=dbengine)

# function to get Image object by file_name and img
def getImage(file_name):
    image = sess.query(Image).filter_by(file_name=file_name).first()
    if not image:
        image = Image(file_name)
        sess.add(image)
        # store image object to base
        sess.commit()
    image.digits_img = None
    return image

def getLastRecognizedImage():
    return sess.query(Image).filter(Image.result!='').order_by(Image.check_time.desc()).first()

def dgDigitById(digit_id):
    digit = sess.query(Digit).get(digit_id)
    return digit

Для обзора показаний и ручного распознавания я написал также маленький web-интерфейс на фреймворкеFlask. Приводить код я тут не буду, кому увлекательно, тот может посмотреть его, а также каждый остальной код на Github.

Интерфейс имеет каждого две страницы, одна для просмотра показаний в виде графика, скажем, за день либо за неделю:

Вторая страница для ручного распознавания цифр. Позже того, как я руками вбил первые 20-30 показаний, робот стал достаточно исправно распознавать показания сам. Иногда исключения все-таки встречаются и распознать цифру не удаётся, это Почаще каждого связано с вращением циферблата:


Тогда доводится вводить пропущенные цифры руками:

Либо дозволено такие показания легко игнорировать, они будут пропущены на графике, и ничего плохого не случится.

В планах ещё доработать скрипт, Дабы отправлял e-mail в случае совпадения нескольких последних показаний.

Вот и всё, о чём хотел рассказать, спасибо, если дочитали до конца.

Источник: programmingmaster.ru

Оставить комментарий
Форум phpBB, русская поддержка форума phpBB
Рейтинг@Mail.ru 2008 - 2017 © BB3x.ru - русская поддержка форума phpBB