From 4a5cc5ad3d479b31e748cabd73d555407b1bc65b Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Sat, 24 Sep 2016 09:07:42 +0200 Subject: [PATCH] Add new component for licence plates processing (OpenAlpr) (#3461) * Add new component for licence plates processing (OpenAlpr) * address balloobbot comments * add to coveragerc * move config from device to base * fix lint * move local api test to voluptous * split render engine * change cloud_api pip string & lint * update requirements_all.txt * fix lint * update cloud_api url * convert base64 byte string to string * Update cloudapi / add configence / add state * fix lint * change state to high confidence plate * fix cloudapi * fix local api detection * add wraper for local api * fix lint * fix wrong import * fix HAAlpr name * update ha-alpr without async * support only eventbased requests with interval 0 * fix minor things * fix lint * fix lint2 --- .coveragerc | 1 + homeassistant/components/openalpr.py | 474 +++++++++++++++++++++++++ homeassistant/components/services.yaml | 12 + requirements_all.txt | 6 + 4 files changed, 493 insertions(+) create mode 100644 homeassistant/components/openalpr.py diff --git a/.coveragerc b/.coveragerc index a0f3edee060..8f12390556c 100644 --- a/.coveragerc +++ b/.coveragerc @@ -206,6 +206,7 @@ omit = homeassistant/components/notify/twitter.py homeassistant/components/notify/xmpp.py homeassistant/components/nuimo_controller.py + homeassistant/components/openalpr.py homeassistant/components/scene/hunterdouglas_powerview.py homeassistant/components/sensor/arest.py homeassistant/components/sensor/bitcoin.py diff --git a/homeassistant/components/openalpr.py b/homeassistant/components/openalpr.py new file mode 100644 index 00000000000..edf041d10f9 --- /dev/null +++ b/homeassistant/components/openalpr.py @@ -0,0 +1,474 @@ +""" +Component that will help set the openalpr for video streams. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/openalpr/ +""" +from base64 import b64encode +import logging +import os +from time import time + +import requests +import voluptuous as vol + +from homeassistant.config import load_yaml_config_file +from homeassistant.const import ( + CONF_API_KEY, CONF_NAME, CONF_USERNAME, CONF_PASSWORD, ATTR_ENTITY_ID, + EVENT_HOMEASSISTANT_STOP, STATE_UNKNOWN) +from homeassistant.components.ffmpeg import ( + get_binary, run_test, CONF_INPUT, CONF_EXTRA_ARGUMENTS) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_component import EntityComponent + +DOMAIN = 'openalpr' +DEPENDENCIES = ['ffmpeg'] +REQUIREMENTS = [ + 'https://github.com/pvizeli/cloudapi/releases/download/1.0.2/' + 'python-1.0.2.zip#cloud_api==1.0.2', + 'ha-alpr==0.2'] + +_LOGGER = logging.getLogger(__name__) + +SERVICE_SCAN = 'scan' +SERVICE_RESTART = 'restart' + +EVENT_FOUND = 'openalpr.found' + +ATTR_PLATE = 'plate' + + +ENGINE_LOCAL = 'local' +ENGINE_CLOUD = 'cloud' + +RENDER_IMAGE = 'image' +RENDER_FFMPEG = 'ffmpeg' + +OPENALPR_REGIONS = [ + 'us', + 'eu', + 'au', + 'auwide', + 'gb', + 'kr', + 'mx', + 'sg', +] + +CONF_RENDER = 'render' +CONF_ENGINE = 'engine' +CONF_REGION = 'region' +CONF_INTERVAL = 'interval' +CONF_ENTITIES = 'entities' +CONF_CONFIDENCE = 'confidence' +CONF_ALPR_BINARY = 'alpr_binary' + +DEFAULT_NAME = 'OpenAlpr' +DEFAULT_ENGINE = ENGINE_LOCAL +DEFAULT_RENDER = RENDER_FFMPEG +DEFAULT_BINARY = 'alpr' +DEFAULT_INTERVAL = 2 +DEFAULT_CONFIDENCE = 80.0 + +DEVICE_SCHEMA = vol.Schema({ + vol.Required(CONF_INPUT): cv.string, + vol.Optional(CONF_INTERVAL, default=DEFAULT_INTERVAL): cv.positive_int, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_RENDER, default=DEFAULT_RENDER): + vol.In([RENDER_IMAGE, RENDER_FFMPEG]), + vol.Optional(CONF_EXTRA_ARGUMENTS): cv.string, + vol.Optional(CONF_USERNAME): cv.string, + vol.Optional(CONF_PASSWORD): cv.string, +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_ENGINE): vol.In([ENGINE_LOCAL, ENGINE_CLOUD]), + vol.Required(CONF_REGION): vol.In(OPENALPR_REGIONS), + vol.Optional(CONF_CONFIDENCE, default=DEFAULT_CONFIDENCE): + vol.Coerce(float), + vol.Optional(CONF_API_KEY): cv.string, + vol.Optional(CONF_ALPR_BINARY, default=DEFAULT_BINARY): cv.string, + vol.Required(CONF_ENTITIES): + vol.All(cv.ensure_list, [DEVICE_SCHEMA]), + }) +}, extra=vol.ALLOW_EXTRA) + + +SERVICE_RESTART_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, +}) + +SERVICE_SCAN_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, +}) + + +def scan(hass, entity_id=None): + """Scan a image immediately.""" + data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} + hass.services.call(DOMAIN, SERVICE_SCAN, data) + + +def restart(hass, entity_id=None): + """Restart a ffmpeg process.""" + data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} + hass.services.call(DOMAIN, SERVICE_RESTART, data) + + +# pylint: disable=too-many-locals +def setup(hass, config): + """Setup the OpenAlpr component.""" + engine = config[DOMAIN].get(CONF_ENGINE) + region = config[DOMAIN].get(CONF_REGION) + confidence = config[DOMAIN].get(CONF_CONFIDENCE) + api_key = config[DOMAIN].get(CONF_API_KEY) + binary = config[DOMAIN].get(CONF_ALPR_BINARY) + use_render_fffmpeg = False + + component = EntityComponent(_LOGGER, DOMAIN, hass) + openalpr_device = [] + + for device in config[DOMAIN].get(CONF_ENTITIES): + input_source = device.get(CONF_INPUT) + render = device.get(CONF_RENDER) + + ## + # create api + if engine == ENGINE_LOCAL: + alpr_api = OpenalprApiLocal( + confidence=confidence, + region=region, + binary=binary, + ) + else: + alpr_api = OpenalprApiCloud( + confidence=confidence, + region=region, + api_key=api_key, + ) + + ## + # Create Alpr device / render engine + if render == RENDER_FFMPEG: + use_render_fffmpeg = True + if not run_test(input_source): + _LOGGER.error("'%s' is not valid ffmpeg input", input_source) + continue + + alpr_dev = OpenalprDeviceFFmpeg( + name=device.get(CONF_NAME), + interval=device.get(CONF_INTERVAL), + api=alpr_api, + input_source=input_source, + extra_arguments=device.get(CONF_EXTRA_ARGUMENTS), + ) + else: + alpr_dev = OpenalprDeviceImage( + name=device.get(CONF_NAME), + interval=device.get(CONF_INTERVAL), + api=alpr_api, + input_source=input_source, + username=device.get(CONF_USERNAME), + password=device.get(CONF_PASSWORD), + ) + + # register shutdown event + openalpr_device.append(alpr_dev) + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, alpr_dev.shutdown) + + component.add_entities(openalpr_device) + + descriptions = load_yaml_config_file( + os.path.join(os.path.dirname(__file__), 'services.yaml')) + + def _handle_service_scan(service): + """Handle service for immediately scan.""" + device_list = component.extract_from_service(service) + + for device in device_list: + device.scan() + + hass.services.register(DOMAIN, SERVICE_SCAN, + _handle_service_scan, + descriptions[DOMAIN][SERVICE_SCAN], + schema=SERVICE_SCAN_SCHEMA) + + # Add restart service only if a device use ffmpeg as render + if not use_render_fffmpeg: + return True + + def _handle_service_restart(service): + """Handle service for restart ffmpeg process.""" + device_list = component.extract_from_service(service) + + for device in device_list: + device.restart() + + hass.services.register(DOMAIN, SERVICE_RESTART, + _handle_service_restart, + descriptions[DOMAIN][SERVICE_RESTART], + schema=SERVICE_RESTART_SCHEMA) + + return True + + +class OpenalprDevice(Entity): + """Represent a openalpr device object for processing stream/images.""" + + def __init__(self, name, interval, api): + """Init image processing.""" + self._name = name + self._interval = interval + self._api = api + self._last = {} + + @property + def state(self): + """Return the state of the entity.""" + confidence = 0 + plate = STATE_UNKNOWN + + # search high plate + for i_pl, i_co in self._last.items(): + if i_co > confidence: + confidence = i_co + plate = i_pl + return plate + + def shutdown(self, event): + """Close stream.""" + if hasattr(self._api, "shutdown"): + self._api.shutdown(event) + + def restart(self): + """Restart stream.""" + raise NotImplementedError() + + def _process_image(self, image): + """Callback for processing image.""" + self._api.process_image(image, self._process_event) + + def _process_event(self, plates): + """Send event with new plates.""" + state_change = False + plates_set = set(plates) + last_set = set(self._last) + new_plates = plates_set - last_set + + # send events + for i_plate in new_plates: + self.hass.bus.fire(EVENT_FOUND, { + ATTR_PLATE: i_plate, + ATTR_ENTITY_ID: self.entity_id + }) + + # update entity store + if last_set <= plates_set: + state_change = True + self._last = plates + + # update HA state + if state_change: + self.update_ha_state() + + @property + def device_state_attributes(self): + """Return device specific state attributes.""" + return {'plates': self._last} + + def scan(self): + """Immediately scan a image.""" + raise NotImplementedError() + + @property + def name(self): + """Return the name of the entity.""" + return self._name + + +class OpenalprDeviceFFmpeg(OpenalprDevice): + """Represent a openalpr device object for processing stream/images.""" + + # pylint: disable=too-many-arguments + def __init__(self, name, interval, api, input_source, + extra_arguments=None): + """Init image processing.""" + from haffmpeg import ImageStream, ImageSingle + + super().__init__(name, interval, api) + self._input_source = input_source + self._extra_arguments = extra_arguments + + if self._interval > 0: + self._ffmpeg = ImageStream(get_binary(), self._process_image) + else: + self._ffmpeg = ImageSingle(get_binary()) + + self._start_ffmpeg() + + def shutdown(self, event): + """Close ffmpeg stream.""" + if self._interval > 0: + self._ffmpeg.close() + + def restart(self): + """Restart ffmpeg stream.""" + if self._interval > 0: + self._ffmpeg.close() + self._start_ffmpeg() + + def scan(self): + """Immediately scan a image.""" + from haffmpeg import IMAGE_PNG + + # process single image + if self._interval == 0: + image = self._ffmpeg.get_image( + self._input_source, + output_format=IMAGE_PNG, + extra_cmd=self._extra_arguments + ) + return self._process_image(image) + + # stream + self._ffmpeg.push_image() + + def _start_ffmpeg(self): + """Start a ffmpeg image stream.""" + from haffmpeg import IMAGE_PNG + if self._interval == 0: + return + + self._ffmpeg.open_stream( + input_source=self._input_source, + interval=self._interval, + output_format=IMAGE_PNG, + extra_cmd=self._extra_arguments, + ) + + @property + def should_poll(self): + """Return True if render is be 'image' or False if 'ffmpeg'.""" + return False + + @property + def available(self): + """Return True if entity is available.""" + return self._interval == 0 or self._ffmpeg.is_running + + +class OpenalprDeviceImage(OpenalprDevice): + """Represent a openalpr device object for processing stream/images.""" + + # pylint: disable=too-many-arguments + def __init__(self, name, interval, api, input_source, + username=None, password=None): + """Init image processing.""" + super().__init__(name, interval, api) + + self._next = time() + self._username = username + self._password = password + self._url = input_source + + def restart(self): + """Fake restart with scan a picture.""" + self.scan() + + def scan(self): + """Immediately scan a image.""" + # send request + if self._username is not None and self._password is not None: + req = requests.get( + self._url, auth=(self._username, self._password), timeout=15) + else: + req = requests.get(self._url, timeout=15) + + # process image + image = req.content + self._process_image(image) + + @property + def should_poll(self): + """Return True if render is be 'image' or False if 'ffmpeg'.""" + return self._interval > 0 + + def update(self): + """Retrieve latest state.""" + if self._next > time(): + return + self.scan() + self._next = time() + self._interval + + +# pylint: disable=too-few-public-methods +class OpenalprApi(object): + """OpenAlpr api class.""" + + def __init__(self, region, confidence): + """Init basic api processing.""" + self._region = region + self._confidence = confidence + + def process_image(self, image, event_callback): + """Callback for processing image.""" + raise NotImplementedError() + + +# pylint: disable=too-few-public-methods +class OpenalprApiCloud(OpenalprApi): + """Use the cloud openalpr api to parse licences plate.""" + + def __init__(self, region, confidence, api_key): + """Init cloud api processing.""" + import openalpr_api + + super().__init__(region=region, confidence=confidence) + self._api = openalpr_api.DefaultApi() + self._api_key = api_key + + def process_image(self, image, event_callback): + """Callback for processing image.""" + result = self._api.recognize_post( + self._api_key, + 'plate', + image="", + image_bytes=str(b64encode(image), 'utf-8'), + country=self._region + ) + + # process result + f_plates = {} + # pylint: disable=no-member + for object_plate in result.plate.results: + plate = object_plate.plate + confidence = object_plate.confidence + if confidence >= self._confidence: + f_plates[plate] = confidence + event_callback(f_plates) + + +class OpenalprApiLocal(OpenalprApi): + """Use local openalpr library to parse licences plate.""" + + def __init__(self, region, confidence, binary): + """Init local api processing.""" + # pylint: disable=import-error + from haalpr import HAAlpr + + super().__init__(region=region, confidence=confidence) + self._api = HAAlpr(binary=binary, country=region) + + def process_image(self, image, event_callback): + """Callback for processing image.""" + result = self._api.recognize_byte(image) + + # process result + f_plates = {} + for found in result: + for plate, confidence in found.items(): + if confidence >= self._confidence: + f_plates[plate] = confidence + event_callback(f_plates) diff --git a/homeassistant/components/services.yaml b/homeassistant/components/services.yaml index 4f79a2ee627..13078418e93 100644 --- a/homeassistant/components/services.yaml +++ b/homeassistant/components/services.yaml @@ -90,6 +90,18 @@ homematic: description: New value example: 1 +openalpr: + scan: + description: Scan immediately a device. + + fields: + entity_id: + description: Name(s) of entities to scan + example: 'openalpr.garage' + + restart: + description: Restart ffmpeg process of device. + zwave: add_node: description: Add a new node to the zwave network. Refer to OZW.log for details. diff --git a/requirements_all.txt b/requirements_all.txt index efb8ee52eb9..02c7f4394c3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -117,6 +117,9 @@ googlemaps==2.4.4 # homeassistant.components.sensor.gpsd gps3==0.33.3 +# homeassistant.components.openalpr +ha-alpr==0.2 + # homeassistant.components.ffmpeg ha-ffmpeg==0.13 @@ -187,6 +190,9 @@ https://github.com/nkgilley/python-ecobee-api/archive/4856a704670c53afe1882178a8 # homeassistant.components.notify.joaoapps_join https://github.com/nkgilley/python-join-api/archive/3e1e849f1af0b4080f551b62270c6d244d5fbcbd.zip#python-join-api==0.0.1 +# homeassistant.components.openalpr +https://github.com/pvizeli/cloudapi/releases/download/1.0.2/python-1.0.2.zip#cloud_api==1.0.2 + # homeassistant.components.switch.edimax https://github.com/rkabadi/pyedimax/archive/365301ce3ff26129a7910c501ead09ea625f3700.zip#pyedimax==0.1