Пользователь
0,0
рейтинг
25 апреля 2014 в 17:40

Разработка → Съёмка показаний счетчика на телефон с последующим распознаванием из песочницы

Вступление


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

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

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

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

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

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


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

Что нам понадобиться?

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

затем нажимаю на смартфоне кнопку Vol Up, теперь видим:


Ставим галочку и нажимаем ОК:
malefic@lepeshka:~$ adb shell input tap 50 600
malefic@lepeshka:~$ adb shell input tap 650 600



Свайпаем, чтобы разлочить экран:
malefic@lepeshka:~$ adb shell input swipe 100 100 500 100



Теперь нужно поставить какой-нибудь vnc-сервер для Android, например, Android VNC Server. Устанавливаем его на смартфон:
malefic@lepeshka:~$ adb install droid+VNC+server+v1.1RC0.apk 
4055 KB/s (2084419 bytes in 0.501s)
	pkg: /data/local/tmp/droid+VNC+server+v1.1RC0.apk
Success

Будим смартфон, так как он скорее всего уснул, пока мы устанавливали vnc-сервер, и свайпаем, чтоб разлочить экран:
malefic@lepeshka:~$ adb shell input keyevent 26
malefic@lepeshka:~$ adb shell input swipe 100 100 500 100

Запускаем vnc-сервер:
malefic@lepeshka:~$ adb shell am start -a android.intent.action.Main -n org.onaips.vnc/.MainActivity



Жмем ОК:
malefic@lepeshka:~$ adb shell input tap 50 900



Жмем Start:
malefic@lepeshka:~$ adb shell input tap 350 300



Жмем предоставить доступ:
malefic@lepeshka:~$ adb shell input tap 600 1000



Отлично, теперь пробрасываем порты через adb:
malefic@lepeshka:~$ adb forward tcp:5801 tcp:5801
malefic@lepeshka:~$ adb forward tcp:5901 tcp:5901

и заходим на смартфон через браузер или любимый vnc клиент.


Далее работаем как с обычным Android телефоном, только через компьютер, удобно сразу настроить WiFi подключение, тогда можно будет заходить по vnc через WiFi, а не держать телефон всё время подключенным к компьютеру (ведь газовый счётчик не всегда расположен в непосредственной близости от компьютера).

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

Периодическая фотосъемка

Устанавливаем приложение Tasker, создаем в нем временной профиль с 00:00 до 23:59 каждые 30 минут выполнять действие — делать фото. Параметры съемки подбираем наиболее подходящие для расположения телефона и счётчика. У меня это макросъемка с обязательной вспышкой.

Вот так, собственно, я расположил свой телефон (вид сверху):


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

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


Выкладываем в Интернет

Для перемещения отснятых изображений счётчика в Интернет я использовал первое попавшееся приложение — FolderSync Lite. Оно умеет синхронизировать папку на смартфоне с папкой, например, на Google диске.

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

Часть 2. Распознавание показаний


Итак, после отправки показаний счётчика в Интернет, меня заинтересовала возможность автоматического распознавания показаний. Это позволит:
  • проводить статистический анализ потребления газа
  • автоматически отслеживать перебои с подачей газа (с возможностью предупреждения по e-mail или sms)


В качестве языка разработки был выбран 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 & authCredentialsOAuth нажать CREATE NEW CLIENT ID, там выбрать Installed applicationOther:


При первом запуске скрипт напишет в консоли 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 в случае совпадения нескольких последних показаний.

Вот и всё, о чём хотел рассказать, спасибо, если дочитали до конца.
Андрей Шуткин @maleficxp
карма
17,0
рейтинг 0,0
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Спецпроект

Самое читаемое Разработка

Комментарии (83)

  • +13
    Идея классная, но не проще было бы поставить температурный датчик, и ориентироваться по нему?
    • +14
      А вы знатный тролль, хотите инвалидировать всю статью одним вопросом? Человек перед Вами душу раскрыл…
      На самом деле, метод подходит для считывания любых числовых показаний, и именно об этом здесь речь. А чем можно было бы лучше сделать — за пределами статьи. Как автор указал, совсем проще взять готовый счетчик, который умеет в цифре отдавать показания.
      • 0
        да я троллить не собирался, просто в новой квартире хочу сделать «умный дом». но так как счетчик газовый уже стоит, то менять его на другой не собирался, но вот использование температурных датчиков для контроля работы котла есть в плане.еще в котле есть RS-232, ищу инфу о возможности управления и контроля через него.
        • 0
          Та же история, счетчик стоит от застройщика, менять на свой резона нет, поэтому и изголяюсь. Котёл-то у меня тоже умный, регулировать мощность в зависимости от температуры теплоносителя и комнатных датчиков температуры умеет, но это ни разу не контроль наличия газа в трубе.
          • 0
            Но котел то умеет контролировать отсутствие газа. Как написано в доках на мой — при отсутствии газа он даже не будет зажигать горелку. Значит можно и с него получать эту информацию. Другое дело что Ваш способ позволяет получать данные со счетчика для оплаты, не подходя к счетчику. Что было бы удобно в моем случае, когда счетчик находится за 1000 км, и цифру в квитанции ставлю наобум :)
            • 0
              По идее да, можно выцепить из котла эту информацию, но для этого нужно дополнительное оборудование. Глянул для своего котла: LAN адаптер — 250 евро, GSM адаптер — 350 евро. А у меня ещё так вышло, что рядом с котлом ни LAN, ни 220В розеток нет.
              • +1
                А что за модель котла? И почему нет 220 от чего он питается?
                • 0
                  Vitopend 100. Котёл напрямую к проводу подключен, нет розетки 220, чтобы подключить ещё что-нибудь для передачи информации.
                  • 0
                    Удалось вытащить инфу из котла? У самого такой же и дом, плюс куча опыта с электроникой и софтом.
                    • 0
                      Имеется в виду наколхозить свой адаптер взамен штатного за 250 евро? Нет, этим я не занимался.
        • +1
          Братцы, признаюсь сразу: с котлами дел не имел, но рискну предположить, что по ту сторону RS-232 (если это он) м.б. микроконтроллер, одна из задач которого — аварийная защита; т.е. будьте внимательны, неверным движением можно вывести систему из строя или чего похуже, да хранит Бог ваши семьи и умные дома.

          Еще такие системы могут иметь RS-485 (брутальный вариант RS-232, электрически несовместим, нужен конвертер интерфейсов). Также легко м.б. и TTL UART (это антипод 485-го; конвертер на USB можно сделать из переходника RS232-USB). И если там именно RS-485, больше шансов получить телеметрию в готовом виде. Вообще программирование МК — это очень увлекательно, но с точки зрения бытовой безопасности мне в такой ситуации спалось бы спокойнее именно с распознаванием изображений.

          В любом случае это территория пром. электроники, без документации лучше с такими девайсами не шутить. И к Интернету если подключать, то только через оптический симплексный интерфейс (который аппаратно только в одну сторону работает:) И все равно подумать о том, кто кроме вас может использовать информацию с датчика и с какими намерениями…

          Резюме: автор, гениально используете хлам, искренне уважаю подход!
          • 0
            Во-во, абсолютно согласен, подключать какие-то свои поделки к дорогой электронике я не рискнул. Накроется потом котёл от этого хэндмэйда медным тазом, вот тогда и будем бюджеты считать.
            Поэтому либо специально для этого разработанное оборудование (а оно стоит денег), либо вот так топорно через фото, зато безобидно. Если что-то и накроется, так только мой телефон от слишком частого фотографирования, но его не жалко, выбросил, да другой поставил, софт-то уже написан.
    • 0
      Тоже вариант. Надо только не забывать, что дом имеет довольно большую теплоёмкость, при отключении отопления остывать он будет день, может два, в зависимости от температуры за бортом. Датчик надо тогда лепить на какой-нибудь из радиаторов в самом холодном углу.
      При этом всё равно как-то нужно решать вопрос с передачей показаний этого датчика в Интернет, да и анализ расхода газа не получится сделать.
      • +3
        Датчик можно установить на трубу сразу после котла.
        • 0
          Это смотря где котёл стоит, если в тёплом углу, то когда он остынет, может быть уже поздно.
          • +1
            А какая температура воды на выходе из котла, когда он нагревает воду?
            • 0
              Смотря как отрегулировать, может до 40 греть, может до 80.
              • +1
                Значит, можно обнаружить, что если в течении определенного времени температура датчика не доходила до 40 градусов, то котел перестал работать.
  • +1
    А я бы сразился с котлом.
  • 0
    Мысли в воздухе витают. Не первый раз замечаю. Месяц назад тоже размышлял, что бы такой проект запилить. А тут раз, и уже кто-то реализовал!
    • 0
      Хо! Так я не один месяц размышлял, прежде чем запилить.
      • 0
        Охотно верю. Дьявол в мелочах. Вы показали довольно много подводных камней. Предложение по поводу совершенствования съема показаний. Они изменяются последовательно. Хотя у верен, что и так хорошее качество. Но скорее всего вы и так где то это учли, тогда мой пардон :)
        • 0
          Сначала думал учитывать, что каждое последующее показание должно быть больше предыдущего, это помогло бы инвалидировать некоторые не верно распознанные показания. Но по факту вышло, что алгоритм вообще не даёт неверных результатов, либо распознаёт верно, либо не распознаёт совсем. Причём процент распознавания близок к 100, неудачные фото можно просто выбрасывать без ущерба результату.
  • +14
    image

    Рядом с 9-кой силуэт кого-то с косой.
    • 0
      все верно, это предупреждение: «я — газовый котел, могу убить»; см. комментарий выше. Да хранит Бог ваши дома и семьи, дорогие друзья.
  • 0
    если вдруг так сложилось, что мы решили всей семьей поехать в отпуск, а на дворе зима, хорошая такая, чтоб -20°C, то последствия могут быть плачевными.
    Это какими? Не знаком со спецификой таких обогревателей, но если имеется в виду только «в доме будет очень холодно», то на «плачевные» это никак не тянет.
    • +7
      Замерзнет то что в трубах. Трубы лопнут. Капремонт системы отопления, а может и еще чего.
  • 0
    интересно!
    наверное, можно было бы взять более серьезный classifier и фичи (SVM+HOG) и не мучиться с определением местоположения циферблата и тп.
  • 0
    Чтобы не подбирать параметры бинаризации можно воспользоваться адаптивной бинаризацией Otsu:

    # Otsu's thresholding after Gaussian filtering
    blur = cv2.GaussianBlur(img,(5,5),0)
    ret3,th3 = cv2.threshold(blur,0,255,cv2.THRESH_BINARY+cv2.THRESH_OTSU)

    Кстати, а качество/скорость распознавания цифр не будет выше, если использовать Tesseract API?
    • 0
      UPD:
      Самый большой контур это и есть наша цифра, выбросим всё, что лежит за его пределами с помощью наложения маски

      Данный этап можно опустить и сразу обрезать большой контур по краям
      • 0
        Я этого и пытался добиться, собственно. Может тогда подскажете, какой функцией это сделать проще, я просто не спец в OpenCV, это мой первый опыт.
        Или имеется в виду, что можно сразу обрезать по описывающему прямоугольнику? Тогда в него может попасть ещё какой-то дополнительный шум, оказавшийся рядом, чего не очень бы хотелось.
    • 0
      Пробовал через Otsu, но это не то немного. На сколько я понял, он просто угадывает границу, по которой нужно делить на белые и черные пиксели, а используемый мной адаптивный алгоритм применяет разные пороговые значения а разных областях изображения, что важно при неравномерной освещённости циферблата.

      Tesseract API не пробовал, но у меня особо не было задачи оптимизировать скорость, 7 цифр за полчаса — не такой большой поток. Не говоря уж о том, что на непосредственно распознавание уходит совсем малая часть времени работы, основные времезатраты — это работа с Google Диском, потом вырезание, и только потом распознавание.
      • 0
        Такой подход хорош, когда в разных частях изображения находятся объекты с разной средней интенсивностью и важно учитывать их локальные гистограммы для того, чтобы их сохранить после бинаризации. Но в Вашем случае объект, который нужно сохранить только один, а все остальное лишь блики и шум. Можете выложить результат с Otsu и без?
        • +1
          Вы правы, что для конкретного изображения, которое рассматривается в данной статье, подойдёт и алгоритм Otsu. Но как раз потому, что оно очень удачное и не имеет бликов:

          Адаптивный:

          Otsu:

          Otsu даже чуть-чуть получше выглядит, так как остаётся меньше шума.
          Гистограмма:


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

          то адаптивный алгоритм с ним также справляется нормально:

          а у Otsu полный провал:

          Гистограмма:

          Теория как раз говорит, что Otsu хорош на изображениях с двухпиковой гистограммой, а блик добавляет пики где-то по середине.
  • 0
    а можно удаленное может быть переключение, чтоб никого не парить.
    самый простой способ на контакты вибромоторчика мобилы ставится реле, оно и включает ваш котел.
  • 0
    А что-то нигде не могу найти, как себя ведут котлы из бюджетного сегмента при отключении газа? Как-то не задумывался никогда об этом, а сейчас тестировать — домашние не оценят.
    • 0
      Мой перестаёт работать и начинает показывать ошибку. Чтобы заработал дальше, его надо выключить и включить заново.
  • 0
    Шикарно, спаибо!

    А нельзя ли обучить алгоритм распознавать «переходящие» цифры? Наделать там паттернов с разным углом поворота, чтобы распознавалось в большую или меньшую сторону?
    • 0
      Можно, но мороки много, а выхлоп в моём случае того не стоит. Процент таких показаний близок к нулю, проще просто игнорировать.
      • 0
        Делать несколько снимков с промежутком и выбирать из них нормальный
        • 0
          Да там механизм и так быстро переворачивает (наравне с самым младшим разрядом), так что вероятность действительно мала.
  • +1
    О, спасибо, что напомнили сдать счетчики — сегодня последний день! :)
  • +4
    Это круто! И чудовищно нерационально. Я не могу с ходу придумать более сложное решение для поставленной задачи, в то время как очевидна куча более простых.
    • +1
      Если речь идёт только о задаче обнаружения перебоя с подачей газа, то да, много более простых решений. Но мне ещё было интересно провести анализ потребления газа в зависимости от времени суток, температуры за окном, настроек котла, бойлера, то есть, когда нужны реальные показания счётчика на протяжении большого времени. Или и для этого у Вас тоже есть куча простых решений? Приведите тогда тут парочку.

      Для меня также одним из важных фактором было использование имеющегося и валяющегося без дела барахла.
      • +1
        Что ж, такой важный фактор, как использование имеющегося барахла, вполне оправдывает даже картонную коробку, примотанную скотчем к счётчику :)
    • 0
      Вы могли бы написать хоть один простой варинат со обычным счетчиком?
      • 0
        Вебкамера и скриптовый FineReader?
        • 0
          И сколько стоят вебкамера и особенно FineReader? Автор же пишет — сделал из того, что было под рукой, за минимальную цену.
          • 0
            Вебкамера стоит дешевле даже разбитого SGS3. Насчёт лицензирования FR не подскажу, но если руководствоваться практикой, принятой на Хабре, и включить в цену затраченное время, то может и лицензия дешевле выйдет.
            • 0
              Имея хобби покупать на ebay разные битые телефоны и собирать из них работающие, я легко нашел у себя в загашнике материнскую плату от sgs3 с неработающим микрофоном (~$10), а также б/у-шную камеру (~$10) и китайскую батарейку (~300р). Также для удобства крепления батарейки к плате использовал фрейм с битым дисплеем.
              Сначала думал обойтись только материнской платой и камерой, но оказалось, что даже при подключении к зарядке плата не включается без батарейки, поэтому пришлось ещё добавить фрейм и батарейку. Но и в этом случае бюджет получился порядка $30


              Разрешение камеры SGS3 — 3264x2448 (8 Мп), веб-камер аналогичного разрешения просто нет, ближайшее разрешение — 5 Мп, и такие камеры стоят порядка 2 т.р.

              ABBYY FineReader 12 Professional (бессрочная лицензия) — 4490 руб. (Версия для скачивания)

              Таким образом, бюджет предлагаемого вами решения составляет примерно 6.5 т.р. и не избавляет от программирования.
              • +2
                Если есть готовность потратиться и не хочется ничего программировать, то вот вам ещё вариант:
                ~$0.001 за распознавание капчи, с периодичностью съемки раз в полчаса в год получается где-то $20.


                • 0
                  А что за сервис?
              • 0
                По приведённой ссылке только NAKATOMI имеет матрицу на 5MPx, на двух других — маркетинг, на самом деле там 2MPx матрица с интерполяцией. У меня у самого Logitech B910 HD стоит, в своё время выбирал её как одну из самых лучших 2MPx вебок.
        • 0
          Спасибо, интересный вариант.
  • +1
    Самый очевидный альтернативный способ: газ, двигаясь по трубе, создает вполне ощутимые акустические вибрации. Простейший датчик их отлично отловит. Дальше нужен простой микроконтроллер, который реагирует на долгое отсутствие звука в трубе.

    А лучше заодно сделать еще датчик затопления, пожарную сигнализацию и все это подключить к одному модему, который будет слать аварийные смс-ки.
    • 0
      Современный смартфон — мощное устройство и для обработки информации, и для сетевого взаимодействия, и для оповещения по GSM/GPRS; а к нему через USB можно подключить МК с датчиками, который в сетевых задачах как раз крайне неуклюж. В этом смысле имеем широкий простор для творчества в смежной области — программирование МК в интеграции с Android, обе платформы достаточно открыты и полны примеров. Еще раз респект автору за умелое использование хлама.

      Но если выводить систему безопасности в Интернет (особенно на ломаном Android), то очень, очень аккуратно. Даже если дальше домашнего WiFi его никто не увидит, не оставляйте ничего без пароля. Берегите себя, свои семьи и имущество;)
  • –4
    Переместите, пожалуйста, статью в хаб «Разработка под Android»
  • +1
    Автор, а не проще ли будет зафиксировать смартфон (чтобы области снимка не съезжали) и просто сравнивать изменения второй (и третьей для контроля) цифры сзади? Как hash-суммы определённой области фотографии, приведенной в чёрно-белый формат.
    А показания можно и самому распознавать. :-P
    Статья хорошая, спасибо. Какой процент ошибок от освещения? Ноль?
    • 0
      Автор, а не проще ли будет зафиксировать смартфон (чтобы области снимка не съезжали)

      По-моему нет, это значит, что если я чуть-чуть задену что-то и сдвину, то придётся править программу, не надо мне такого счастья.
      Какой процент ошибок от освещения? Ноль?

      Поначалу были, но с помощью скотча и бумаги я доработал конструкцию так, чтобы вспышка била не напрямую, а отражаясь от боковой стенки, а внешнее освещение внутрь коробки не попадало. После этого проблемы с бликами и фокусировкой исчезли, теперь ошибки даёт только смена цифр.
      • +1
        А если вырезать фрейм из картонки черного цвета, или наоборот белого, чтобы дырки в нем были под дисплей счетчика, и там еще дырку под светодиод, что отражает аварийное состояние ( мигает как написано на вашем счетчике )? То есть зачем программно обрабатывать специфику конкретной задачи, когда можно обойтись картонкой? А в дырку для дисплея счетчика налепить какую-нибудь противоотражательную пленку.
        • 0
          картонка с дыркой сама по себе проблемы не решит, возможно упростит процесс поиска, но суть та же останется.
          • 0
            Да, я именно про упрощение поиска индикатора. Если сделать черный фрейм, и еще зеленую пленку светофильтр поставить (чтобы сделать красный фон черным в последних сегментах), то можно будет алгоритмом искать оставшиеся зеленые цифры, так как всего остальное полагается будет темным.
  • +2
    Красиво, впечатляюще. Особенно прекрасно, что счётчик почти за 9 тысяч рублей не умеет отдавать показания.
    У меня примерно такая-же проблема с одним отличием — перебои с газом практически не случаются, а вот доблестные электросети имеют свойства обрубать питание без предупреждения. Так как разбитых самсунгов в хозяйстве не водилось, а Ардуинка с ethernet-шилдой и пригоршней DS18B20 лежали в ящике стола было решено использовать её. Термодатчики были закреплены на трубах подачи/обратки и горячего водоснабжения, написан простенький скетч, отдающий показания в MySQL домашнего сервера. А уж из него данные передавались на narodmon и "секретную" страничку.
    • 0
      У нас тоже проблемы с электричеством бывают гораздо чаще, чем с газом. Но к счастью после отключения и включения электричества котёл сам включается и начинает работать, а вот если «мигнёт» газ, то не начинает.
      • +1
        Кстати, «мигание» газа недопустимо ни по каким нормам. Можно смело жаловаться куда надо.
        Это хорошо, что котлы умные, а если кто поставит себе АОГВ, у которых защита через год работать перестаёт? Это всё, конец: погасло пламя, подачу возобновили, газ пошел. Хорошо, если вытяжка нормальная…

        У нас таких перебоев нет. В случае любых ТО на линии за неделю вывешивают объявления, а за час перед работами ещё обходят квартиры или домовладения.
        А косяки случаются, особенно в сильный штормовой ветер у некоторых в домах АОГВ задувает. Просто сбивает пламя обратной тягой, газ продолжает идти…
        • 0
          Кроме ТО ещё бывают аварии, о них за неделю не предупреждают. Я не говорю, что часто, но у нас за последний год два раза отключали газ.
  • 0
    Спасибо, классная статья! Так можно, например, распознавать RSA ключи на брелках-генераторах.
    • 0
      • 0
        Да, точно, много похожего, жаль что я не увидел этой статьи раньше, это бы сильно упростило жизнь.
  • 0
    Который год мечтаю о таком же софте для аналоговых шкал…
    • +1
      Почти все аналоговые шкалы — это миллиамперметры. Не вижу проблем.
  • 0
    Чем можно контролировать (сбор с заданными промежутками времени и хранение) показания с водосчётчиков Valtec с импульсным выходом (VLF-R-UNIVERSAL I)?
    • 0
      Все зависит от бюджета. В простейшем случае та же Ардуинка со счётчиком импульсов.
      • 0
        А в более сложном случае?
        Все варианты предполагают нечто DIY-ориентированное без возможности приобрести готовое решение в магазине?
        • 0
          Я же говорю, всё от бюджета зависит. Вот первое, что Яндекс выдал. Не сочтите за рекламу.
          • 0
            Спасибо, не сочту. В первом приближении бюджет не лимитирован. (Жаба, молчать). Каким поисковым запросом Вы выпытали эту ссылку у Яндекса? :)
  • 0
    Вашу бы энергию — да в мирных целях :)

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

    Но всё равно, было интересно читать, как забивать пинцеты воробьями, респект.
  • 0
    Снимать показания с счетчиков можно намного проще. Из инструкции на ваш счетчик:
    Отсчетное устройство показывает объем газа, прошедшего через счетчик. Первый роликовый барабан сумматора снабжен отражающей меткой, предназначенной для автоматического снятия показаний счетчика с помощью оптических датчиков.
  • 0
    Автору искреннее уважение за идею и умение комбинировать работы по железу и софту.

    По сути, продолжая тему использования старых телефонов для всего и вся — можно делать программный комплекс для соединения таких решений для приложения к умному дому «из газет и веток» :). Ни стеб ни в коем случае — сам люблю подобные решения — из них можно вырастить что-то большее. Например, ваша реализация — это частный вид программного датчика, который может быть подсоединен к общей системе. Сюда же старый телефон в качестве детектора движений. Телефоны — теже компьютеры, только маленькие. Тут основной вопрос в надежности таких решений.

    И вместо телефонов также можно использовать микрокомпьютеры, которые сейчас производятся китаем в больших количествах.
    • 0
      Можно и микрокомпьютеры, но под рукой валялся именно старый телефон.
      • 0
        Старый телефон — абсолютно нормальная идея. Куча мелких стартапов рождено на идее использования старого телефона, начиная от скрытой видеокамеры и заканчивая GPS трекером.

Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.