mirror of
https://github.com/home-assistant/core.git
synced 2025-07-23 13:17:32 +00:00
Add sighthound timestamped file (#32202)
* Update image_processing.py Adds save timestamp file and adds last_detection attribute * Update test_image_processing.py Adds test * Adds assert pil_img.save.call_args * Test timestamp filename * Add test bad data * Update test_image_processing.py * Fix bad image data test * Update homeassistant/components/sighthound/image_processing.py Co-Authored-By: Martin Hjelmare <marhje52@gmail.com> Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
parent
e416f17e4d
commit
3ca97a0517
@ -3,7 +3,7 @@ import io
|
|||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from PIL import Image, ImageDraw
|
from PIL import Image, ImageDraw, UnidentifiedImageError
|
||||||
import simplehound.core as hound
|
import simplehound.core as hound
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
@ -17,6 +17,7 @@ from homeassistant.components.image_processing import (
|
|||||||
from homeassistant.const import ATTR_ENTITY_ID, CONF_API_KEY
|
from homeassistant.const import ATTR_ENTITY_ID, CONF_API_KEY
|
||||||
from homeassistant.core import split_entity_id
|
from homeassistant.core import split_entity_id
|
||||||
import homeassistant.helpers.config_validation as cv
|
import homeassistant.helpers.config_validation as cv
|
||||||
|
import homeassistant.util.dt as dt_util
|
||||||
from homeassistant.util.pil import draw_box
|
from homeassistant.util.pil import draw_box
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
@ -27,6 +28,8 @@ ATTR_BOUNDING_BOX = "bounding_box"
|
|||||||
ATTR_PEOPLE = "people"
|
ATTR_PEOPLE = "people"
|
||||||
CONF_ACCOUNT_TYPE = "account_type"
|
CONF_ACCOUNT_TYPE = "account_type"
|
||||||
CONF_SAVE_FILE_FOLDER = "save_file_folder"
|
CONF_SAVE_FILE_FOLDER = "save_file_folder"
|
||||||
|
CONF_SAVE_TIMESTAMPTED_FILE = "save_timestamped_file"
|
||||||
|
DATETIME_FORMAT = "%Y-%m-%d_%H:%M:%S"
|
||||||
DEV = "dev"
|
DEV = "dev"
|
||||||
PROD = "prod"
|
PROD = "prod"
|
||||||
|
|
||||||
@ -35,6 +38,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
|
|||||||
vol.Required(CONF_API_KEY): cv.string,
|
vol.Required(CONF_API_KEY): cv.string,
|
||||||
vol.Optional(CONF_ACCOUNT_TYPE, default=DEV): vol.In([DEV, PROD]),
|
vol.Optional(CONF_ACCOUNT_TYPE, default=DEV): vol.In([DEV, PROD]),
|
||||||
vol.Optional(CONF_SAVE_FILE_FOLDER): cv.isdir,
|
vol.Optional(CONF_SAVE_FILE_FOLDER): cv.isdir,
|
||||||
|
vol.Optional(CONF_SAVE_TIMESTAMPTED_FILE, default=False): cv.boolean,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -58,7 +62,11 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
|
|||||||
entities = []
|
entities = []
|
||||||
for camera in config[CONF_SOURCE]:
|
for camera in config[CONF_SOURCE]:
|
||||||
sighthound = SighthoundEntity(
|
sighthound = SighthoundEntity(
|
||||||
api, camera[CONF_ENTITY_ID], camera.get(CONF_NAME), save_file_folder
|
api,
|
||||||
|
camera[CONF_ENTITY_ID],
|
||||||
|
camera.get(CONF_NAME),
|
||||||
|
save_file_folder,
|
||||||
|
config[CONF_SAVE_TIMESTAMPTED_FILE],
|
||||||
)
|
)
|
||||||
entities.append(sighthound)
|
entities.append(sighthound)
|
||||||
add_entities(entities)
|
add_entities(entities)
|
||||||
@ -67,7 +75,9 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
|
|||||||
class SighthoundEntity(ImageProcessingEntity):
|
class SighthoundEntity(ImageProcessingEntity):
|
||||||
"""Create a sighthound entity."""
|
"""Create a sighthound entity."""
|
||||||
|
|
||||||
def __init__(self, api, camera_entity, name, save_file_folder):
|
def __init__(
|
||||||
|
self, api, camera_entity, name, save_file_folder, save_timestamped_file
|
||||||
|
):
|
||||||
"""Init."""
|
"""Init."""
|
||||||
self._api = api
|
self._api = api
|
||||||
self._camera = camera_entity
|
self._camera = camera_entity
|
||||||
@ -77,15 +87,19 @@ class SighthoundEntity(ImageProcessingEntity):
|
|||||||
camera_name = split_entity_id(camera_entity)[1]
|
camera_name = split_entity_id(camera_entity)[1]
|
||||||
self._name = f"sighthound_{camera_name}"
|
self._name = f"sighthound_{camera_name}"
|
||||||
self._state = None
|
self._state = None
|
||||||
|
self._last_detection = None
|
||||||
self._image_width = None
|
self._image_width = None
|
||||||
self._image_height = None
|
self._image_height = None
|
||||||
self._save_file_folder = save_file_folder
|
self._save_file_folder = save_file_folder
|
||||||
|
self._save_timestamped_file = save_timestamped_file
|
||||||
|
|
||||||
def process_image(self, image):
|
def process_image(self, image):
|
||||||
"""Process an image."""
|
"""Process an image."""
|
||||||
detections = self._api.detect(image)
|
detections = self._api.detect(image)
|
||||||
people = hound.get_people(detections)
|
people = hound.get_people(detections)
|
||||||
self._state = len(people)
|
self._state = len(people)
|
||||||
|
if self._state > 0:
|
||||||
|
self._last_detection = dt_util.now().strftime(DATETIME_FORMAT)
|
||||||
|
|
||||||
metadata = hound.get_metadata(detections)
|
metadata = hound.get_metadata(detections)
|
||||||
self._image_width = metadata["image_width"]
|
self._image_width = metadata["image_width"]
|
||||||
@ -109,7 +123,11 @@ class SighthoundEntity(ImageProcessingEntity):
|
|||||||
|
|
||||||
def save_image(self, image, people, directory):
|
def save_image(self, image, people, directory):
|
||||||
"""Save a timestamped image with bounding boxes around targets."""
|
"""Save a timestamped image with bounding boxes around targets."""
|
||||||
|
try:
|
||||||
img = Image.open(io.BytesIO(bytearray(image))).convert("RGB")
|
img = Image.open(io.BytesIO(bytearray(image))).convert("RGB")
|
||||||
|
except UnidentifiedImageError:
|
||||||
|
_LOGGER.warning("Sighthound unable to process image, bad data")
|
||||||
|
return
|
||||||
draw = ImageDraw.Draw(img)
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
for person in people:
|
for person in people:
|
||||||
@ -117,9 +135,15 @@ class SighthoundEntity(ImageProcessingEntity):
|
|||||||
person["boundingBox"], self._image_width, self._image_height
|
person["boundingBox"], self._image_width, self._image_height
|
||||||
)
|
)
|
||||||
draw_box(draw, box, self._image_width, self._image_height)
|
draw_box(draw, box, self._image_width, self._image_height)
|
||||||
|
|
||||||
latest_save_path = directory / f"{self._name}_latest.jpg"
|
latest_save_path = directory / f"{self._name}_latest.jpg"
|
||||||
img.save(latest_save_path)
|
img.save(latest_save_path)
|
||||||
|
|
||||||
|
if self._save_timestamped_file:
|
||||||
|
timestamp_save_path = directory / f"{self._name}_{self._last_detection}.jpg"
|
||||||
|
img.save(timestamp_save_path)
|
||||||
|
_LOGGER.info("Sighthound saved file %s", timestamp_save_path)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def camera_entity(self):
|
def camera_entity(self):
|
||||||
"""Return camera entity id from process pictures."""
|
"""Return camera entity id from process pictures."""
|
||||||
@ -144,3 +168,11 @@ class SighthoundEntity(ImageProcessingEntity):
|
|||||||
def unit_of_measurement(self):
|
def unit_of_measurement(self):
|
||||||
"""Return the unit of measurement."""
|
"""Return the unit of measurement."""
|
||||||
return ATTR_PEOPLE
|
return ATTR_PEOPLE
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_state_attributes(self):
|
||||||
|
"""Return the attributes."""
|
||||||
|
attr = {}
|
||||||
|
if self._last_detection:
|
||||||
|
attr["last_person"] = self._last_detection
|
||||||
|
return attr
|
||||||
|
@ -1,8 +1,11 @@
|
|||||||
"""Tests for the Sighthound integration."""
|
"""Tests for the Sighthound integration."""
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
|
import datetime
|
||||||
import os
|
import os
|
||||||
from unittest.mock import patch
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from PIL import UnidentifiedImageError
|
||||||
import pytest
|
import pytest
|
||||||
import simplehound.core as hound
|
import simplehound.core as hound
|
||||||
|
|
||||||
@ -40,11 +43,13 @@ MOCK_DETECTIONS = {
|
|||||||
"requestId": "545cec700eac4d389743e2266264e84b",
|
"requestId": "545cec700eac4d389743e2266264e84b",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MOCK_NOW = datetime.datetime(2020, 2, 20, 10, 5, 3)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_detections():
|
def mock_detections():
|
||||||
"""Return a mock detection."""
|
"""Return a mock detection."""
|
||||||
with patch(
|
with mock.patch(
|
||||||
"simplehound.core.cloud.detect", return_value=MOCK_DETECTIONS
|
"simplehound.core.cloud.detect", return_value=MOCK_DETECTIONS
|
||||||
) as detection:
|
) as detection:
|
||||||
yield detection
|
yield detection
|
||||||
@ -53,16 +58,35 @@ def mock_detections():
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_image():
|
def mock_image():
|
||||||
"""Return a mock camera image."""
|
"""Return a mock camera image."""
|
||||||
with patch(
|
with mock.patch(
|
||||||
"homeassistant.components.demo.camera.DemoCamera.camera_image",
|
"homeassistant.components.demo.camera.DemoCamera.camera_image",
|
||||||
return_value=b"Test",
|
return_value=b"Test",
|
||||||
) as image:
|
) as image:
|
||||||
yield image
|
yield image
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_bad_image_data():
|
||||||
|
"""Mock bad image data."""
|
||||||
|
with mock.patch(
|
||||||
|
"homeassistant.components.sighthound.image_processing.Image.open",
|
||||||
|
side_effect=UnidentifiedImageError,
|
||||||
|
) as bad_data:
|
||||||
|
yield bad_data
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_now():
|
||||||
|
"""Return a mock now datetime."""
|
||||||
|
with mock.patch("homeassistant.util.dt.now", return_value=MOCK_NOW) as now_dt:
|
||||||
|
yield now_dt
|
||||||
|
|
||||||
|
|
||||||
async def test_bad_api_key(hass, caplog):
|
async def test_bad_api_key(hass, caplog):
|
||||||
"""Catch bad api key."""
|
"""Catch bad api key."""
|
||||||
with patch("simplehound.core.cloud.detect", side_effect=hound.SimplehoundException):
|
with mock.patch(
|
||||||
|
"simplehound.core.cloud.detect", side_effect=hound.SimplehoundException
|
||||||
|
):
|
||||||
await async_setup_component(hass, ip.DOMAIN, VALID_CONFIG)
|
await async_setup_component(hass, ip.DOMAIN, VALID_CONFIG)
|
||||||
assert "Sighthound error" in caplog.text
|
assert "Sighthound error" in caplog.text
|
||||||
assert not hass.states.get(VALID_ENTITY_ID)
|
assert not hass.states.get(VALID_ENTITY_ID)
|
||||||
@ -97,6 +121,21 @@ async def test_process_image(hass, mock_image, mock_detections):
|
|||||||
assert len(person_events) == 2
|
assert len(person_events) == 2
|
||||||
|
|
||||||
|
|
||||||
|
async def test_catch_bad_image(
|
||||||
|
hass, caplog, mock_image, mock_detections, mock_bad_image_data
|
||||||
|
):
|
||||||
|
"""Process an image."""
|
||||||
|
valid_config_save_file = deepcopy(VALID_CONFIG)
|
||||||
|
valid_config_save_file[ip.DOMAIN].update({sh.CONF_SAVE_FILE_FOLDER: TEST_DIR})
|
||||||
|
await async_setup_component(hass, ip.DOMAIN, valid_config_save_file)
|
||||||
|
assert hass.states.get(VALID_ENTITY_ID)
|
||||||
|
|
||||||
|
data = {ATTR_ENTITY_ID: VALID_ENTITY_ID}
|
||||||
|
await hass.services.async_call(ip.DOMAIN, ip.SERVICE_SCAN, service_data=data)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert "Sighthound unable to process image" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
async def test_save_image(hass, mock_image, mock_detections):
|
async def test_save_image(hass, mock_image, mock_detections):
|
||||||
"""Save a processed image."""
|
"""Save a processed image."""
|
||||||
valid_config_save_file = deepcopy(VALID_CONFIG)
|
valid_config_save_file = deepcopy(VALID_CONFIG)
|
||||||
@ -104,7 +143,7 @@ async def test_save_image(hass, mock_image, mock_detections):
|
|||||||
await async_setup_component(hass, ip.DOMAIN, valid_config_save_file)
|
await async_setup_component(hass, ip.DOMAIN, valid_config_save_file)
|
||||||
assert hass.states.get(VALID_ENTITY_ID)
|
assert hass.states.get(VALID_ENTITY_ID)
|
||||||
|
|
||||||
with patch(
|
with mock.patch(
|
||||||
"homeassistant.components.sighthound.image_processing.Image.open"
|
"homeassistant.components.sighthound.image_processing.Image.open"
|
||||||
) as pil_img_open:
|
) as pil_img_open:
|
||||||
pil_img = pil_img_open.return_value
|
pil_img = pil_img_open.return_value
|
||||||
@ -115,3 +154,34 @@ async def test_save_image(hass, mock_image, mock_detections):
|
|||||||
state = hass.states.get(VALID_ENTITY_ID)
|
state = hass.states.get(VALID_ENTITY_ID)
|
||||||
assert state.state == "2"
|
assert state.state == "2"
|
||||||
assert pil_img.save.call_count == 1
|
assert pil_img.save.call_count == 1
|
||||||
|
|
||||||
|
directory = Path(TEST_DIR)
|
||||||
|
latest_save_path = directory / "sighthound_demo_camera_latest.jpg"
|
||||||
|
assert pil_img.save.call_args_list[0] == mock.call(latest_save_path)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_timestamped_image(hass, mock_image, mock_detections, mock_now):
|
||||||
|
"""Save a processed image."""
|
||||||
|
valid_config_save_ts_file = deepcopy(VALID_CONFIG)
|
||||||
|
valid_config_save_ts_file[ip.DOMAIN].update({sh.CONF_SAVE_FILE_FOLDER: TEST_DIR})
|
||||||
|
valid_config_save_ts_file[ip.DOMAIN].update({sh.CONF_SAVE_TIMESTAMPTED_FILE: True})
|
||||||
|
await async_setup_component(hass, ip.DOMAIN, valid_config_save_ts_file)
|
||||||
|
assert hass.states.get(VALID_ENTITY_ID)
|
||||||
|
|
||||||
|
with mock.patch(
|
||||||
|
"homeassistant.components.sighthound.image_processing.Image.open"
|
||||||
|
) as pil_img_open:
|
||||||
|
pil_img = pil_img_open.return_value
|
||||||
|
pil_img = pil_img.convert.return_value
|
||||||
|
data = {ATTR_ENTITY_ID: VALID_ENTITY_ID}
|
||||||
|
await hass.services.async_call(ip.DOMAIN, ip.SERVICE_SCAN, service_data=data)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
state = hass.states.get(VALID_ENTITY_ID)
|
||||||
|
assert state.state == "2"
|
||||||
|
assert pil_img.save.call_count == 2
|
||||||
|
|
||||||
|
directory = Path(TEST_DIR)
|
||||||
|
timestamp_save_path = (
|
||||||
|
directory / "sighthound_demo_camera_2020-02-20_10:05:03.jpg"
|
||||||
|
)
|
||||||
|
assert pil_img.save.call_args_list[1] == mock.call(timestamp_save_path)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user