Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for cropping pictures in proxy camera #18431

Merged
merged 2 commits into from
Nov 22, 2018
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 98 additions & 19 deletions homeassistant/components/camera/proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,13 @@
import voluptuous as vol

from homeassistant.components.camera import PLATFORM_SCHEMA, Camera
from homeassistant.const import CONF_ENTITY_ID, CONF_NAME, HTTP_HEADER_HA_AUTH
from homeassistant.const import CONF_ENTITY_ID, CONF_NAME, CONF_MODE, \
HTTP_HEADER_HA_AUTH
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.util.async_ import run_coroutine_threadsafe
import homeassistant.util.dt as dt_util
from . import async_get_still_stream
from homeassistant.components.camera import async_get_still_stream

REQUIREMENTS = ['pillow==5.2.0']

Expand All @@ -26,21 +27,34 @@
CONF_IMAGE_QUALITY = 'image_quality'
CONF_IMAGE_REFRESH_RATE = 'image_refresh_rate'
CONF_MAX_IMAGE_WIDTH = 'max_image_width'
CONF_MAX_IMAGE_HEIGHT = 'max_image_height'
CONF_MAX_STREAM_WIDTH = 'max_stream_width'
CONF_MAX_STREAM_HEIGHT = 'max_stream_height'
CONF_IMAGE_TOP = 'image_top'
CONF_IMAGE_LEFT = 'image_left'
CONF_STREAM_QUALITY = 'stream_quality'

MODE_RESIZE = 'resize'
MODE_CROP = 'crop'

DEFAULT_BASENAME = "Camera Proxy"
DEFAULT_QUALITY = 75

PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
vol.Required(CONF_ENTITY_ID): cv.entity_id,
vol.Optional(CONF_NAME): cv.string,
vol.Optional(CONF_CACHE_IMAGES, False): cv.boolean,
vol.Optional(CONF_FORCE_RESIZE, False): cv.boolean,
vol.Optional(CONF_MODE, default=MODE_RESIZE):
vol.In([MODE_RESIZE, MODE_CROP]),
vol.Optional(CONF_IMAGE_QUALITY): int,
vol.Optional(CONF_IMAGE_REFRESH_RATE): float,
vol.Optional(CONF_MAX_IMAGE_WIDTH): int,
vol.Optional(CONF_MAX_IMAGE_HEIGHT): int,
vol.Optional(CONF_MAX_STREAM_WIDTH): int,
vol.Optional(CONF_NAME): cv.string,
vol.Optional(CONF_MAX_STREAM_HEIGHT): int,
vol.Optional(CONF_IMAGE_LEFT): int,
vol.Optional(CONF_IMAGE_TOP): int,
vol.Optional(CONF_STREAM_QUALITY): int,
})

Expand All @@ -51,26 +65,37 @@ async def async_setup_platform(
async_add_entities([ProxyCamera(hass, config)])


def _resize_image(image, opts):
"""Resize image."""
def _precheck_image(image, opts):
"""Perform some pre-checks on the given image."""
from PIL import Image
import io

if not opts:
return image

quality = opts.quality or DEFAULT_QUALITY
new_width = opts.max_width

raise ValueError
try:
img = Image.open(io.BytesIO(image))
except IOError:
return image
_LOGGER.warning("Failed to open image")
raise ValueError
imgfmt = str(img.format)
if imgfmt not in ('PNG', 'JPEG'):
_LOGGER.debug("Image is of unsupported type: %s", imgfmt)
_LOGGER.warning("Image is of unsupported type: %s", imgfmt)
raise ValueError
return img


def _resize_image(image, opts):
glpatcern marked this conversation as resolved.
Show resolved Hide resolved
"""Resize image."""
from PIL import Image
import io

try:
img = _precheck_image(image, opts)
except ValueError:
return image

quality = opts.quality or DEFAULT_QUALITY
new_width = opts.max_width
(old_width, old_height) = img.size
old_size = len(image)
if old_width <= new_width:
Expand All @@ -87,7 +112,7 @@ def _resize_image(image, opts):
img.save(imgbuf, 'JPEG', optimize=True, quality=quality)
newimage = imgbuf.getvalue()
if not opts.force_resize and len(newimage) >= old_size:
_LOGGER.debug("Using original image(%d bytes) "
_LOGGER.debug("Using original image (%d bytes) "
"because resized image (%d bytes) is not smaller",
old_size, len(newimage))
return image
Expand All @@ -98,12 +123,50 @@ def _resize_image(image, opts):
return newimage


def _crop_image(image, opts):
glpatcern marked this conversation as resolved.
Show resolved Hide resolved
"""Crop image."""
import io

try:
img = _precheck_image(image, opts)
except ValueError:
return image

quality = opts.quality or DEFAULT_QUALITY
(old_width, old_height) = img.size
old_size = len(image)
if opts.top is None:
opts.top = 0
if opts.left is None:
opts.left = 0
if opts.max_width is None or opts.max_width > old_width - opts.left:
opts.max_width = old_width - opts.left
if opts.max_height is None or opts.max_height > old_height - opts.top:
opts.max_height = old_height - opts.top

img = img.crop((opts.left, opts.top,
opts.left+opts.max_width, opts.top+opts.max_height))
imgbuf = io.BytesIO()
img.save(imgbuf, 'JPEG', optimize=True, quality=quality)
newimage = imgbuf.getvalue()

_LOGGER.debug(
"Cropped image from (%dx%d - %d bytes) to (%dx%d - %d bytes)",
old_width, old_height, old_size, opts.max_width, opts.max_height,
len(newimage))
return newimage


class ImageOpts():
"""The representation of image options."""

def __init__(self, max_width, quality, force_resize):
def __init__(self, max_width, max_height, left, top,
quality, force_resize):
"""Initialize image options."""
self.max_width = max_width
self.max_height = max_height
self.left = left
self.top = top
self.quality = quality
self.force_resize = force_resize

Expand All @@ -125,11 +188,18 @@ def __init__(self, hass, config):
"{} - {}".format(DEFAULT_BASENAME, self._proxied_camera))
self._image_opts = ImageOpts(
config.get(CONF_MAX_IMAGE_WIDTH),
config.get(CONF_MAX_IMAGE_HEIGHT),
config.get(CONF_IMAGE_LEFT),
config.get(CONF_IMAGE_TOP),
config.get(CONF_IMAGE_QUALITY),
config.get(CONF_FORCE_RESIZE))

self._stream_opts = ImageOpts(
config.get(CONF_MAX_STREAM_WIDTH), config.get(CONF_STREAM_QUALITY),
config.get(CONF_MAX_STREAM_WIDTH),
config.get(CONF_MAX_STREAM_HEIGHT),
config.get(CONF_IMAGE_LEFT),
config.get(CONF_IMAGE_TOP),
config.get(CONF_STREAM_QUALITY),
True)

self._image_refresh_rate = config.get(CONF_IMAGE_REFRESH_RATE)
Expand All @@ -141,6 +211,7 @@ def __init__(self, hass, config):
self._headers = (
{HTTP_HEADER_HA_AUTH: self.hass.config.api.api_password}
if self.hass.config.api.api_password is not None else None)
self._mode = config.get(CONF_MODE)

def camera_image(self):
"""Return camera image."""
Expand All @@ -162,8 +233,12 @@ async def async_camera_image(self):
_LOGGER.error("Error getting original camera image")
return self._last_image

image = await self.hass.async_add_job(
_resize_image, image.content, self._image_opts)
if self._mode == MODE_RESIZE:
job = _resize_image
else:
job = _crop_image
image = await self.hass.async_add_executor_job(
job, image.content, self._image_opts)
glpatcern marked this conversation as resolved.
Show resolved Hide resolved

if self._cache_images:
self._last_image = image
Expand Down Expand Up @@ -194,5 +269,9 @@ async def _async_stream_image(self):
except HomeAssistantError:
raise asyncio.CancelledError

return await self.hass.async_add_job(
_resize_image, image.content, self._stream_opts)
if self._mode == MODE_RESIZE:
job = _resize_image
else:
job = _crop_image
return await self.hass.async_add_executor_job(
job, image.content, self._stream_opts)
glpatcern marked this conversation as resolved.
Show resolved Hide resolved