Skip to content

Commit

Permalink
v0.6.1 color_temperature step size == 1
Browse files Browse the repository at this point in the history
lookup tables are boring
  • Loading branch information
= committed Sep 7, 2023
1 parent 5e39bef commit 459b874
Show file tree
Hide file tree
Showing 8 changed files with 136 additions and 173 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
# v0.6.1
`color_temperature` now accepts values from 1.000 to 40.000 K, step size 1.
New default is 6.600

# v0.6
New features:
- color_temperature
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Additionally, overall brightness can be calculated and applied within adjustable
| ambient_extract_url | * | URI | - | The full URL (including schema, `http://`, `https://`) of the image to process
| ambient_extract_path | * | String | - | The full path to the image file on local storage we’ll process
| entity_id | No | String | - | The light(s) we’ll set color and/or brightness of
| color_temperature | Yes | Int: 1.000 to 10.000 in steps of 500. Default: 6700 K | 6.700 K | Apply color temperature correction
| color_temperature | Yes | Int: 1.000 to 40.000 | 6.600 K | Apply color temperature correction
| brightness_auto | Yes | Boolean | False | Detect and set brightness
| brightness_mode | Yes | mean rms natural dominant | mean | Brightness calculation method. `mean` and `rms` use a grayscale image, `natural` uses perceived brightness, `dominant` the same color as for RGB (fastest).
| brightness_min | Yes | Int: 0 to 255 | 2 | Minimal brightness. `< 2` means off for most devices.
Expand Down Expand Up @@ -104,7 +104,7 @@ data_template:
brightness_min: "{{ states('input_number.ambilight_brightness_min') }}"
brightness_max: "{{ states('input_number.ambilight_brightness_max') }}"
```
Create `ambilight_color_temperature` as Number from 1.000 to 10.000 with a step size of 500.
Create `ambilight_color_temperature` as Number from 1.000 to 40.000, step size 1.


### Full automation YAML
Expand Down
205 changes: 38 additions & 167 deletions custom_components/ambient_extractor/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,13 @@
import asyncio
import io
import logging

from PIL import UnidentifiedImageError, Image, ImageStat
from PIL import UnidentifiedImageError
import aiohttp
import async_timeout
from colorthief import ColorThief
import voluptuous as vol
import math
from tempfile import TemporaryFile

from homeassistant.components.light import (
ATTR_RGB_COLOR,
# ATTR_BRIGHTNESS_PCT,
ATTR_BRIGHTNESS,
DOMAIN as LIGHT_DOMAIN,
LIGHT_TURN_ON_SCHEMA,
Expand All @@ -25,145 +20,49 @@
from homeassistant.helpers.typing import ConfigType

from .const import (
ATTR_PATH,
ATTR_URL,
DOMAIN,
SERVICE_TURN_ON,
ATTR_PATH,
ATTR_URL,
ATTR_COLOR_TEMPERATURE,

ATTR_BRIGHTNESS_AUTO,
ATTR_BRIGHTNESS_MODE,
ATTR_BRIGHTNESS_MIN,
ATTR_BRIGHTNESS_MAX,

ATTR_CROP_X,
ATTR_CROP_Y,
ATTR_CROP_W,
ATTR_CROP_H,
)
from .color_temperature import apply_color_temperature
from .extract_brightness import get_brightness
from .extract_color import get_file, get_color_from_image, get_color_from_file, get_cropped_image

_LOGGER = logging.getLogger(__name__)

# Extend the existing light.turn_on service schema
SERVICE_SCHEMA = vol.All(
cv.has_at_least_one_key(ATTR_URL, ATTR_PATH),
cv.make_entity_service_schema(
{
**LIGHT_TURN_ON_SCHEMA,
vol.Exclusive(ATTR_PATH, "ambient_extractor"): cv.isfile,
vol.Exclusive(ATTR_URL, "ambient_extractor"): cv.url,

vol.Optional(ATTR_COLOR_TEMPERATURE, default=False): cv.positive_int,
vol.Optional(ATTR_BRIGHTNESS_AUTO, default=False): cv.boolean,
vol.Optional(ATTR_BRIGHTNESS_MODE, default="mean"): cv.string,
vol.Optional(ATTR_BRIGHTNESS_MIN, default=2): cv.positive_int,
vol.Optional(ATTR_BRIGHTNESS_MAX, default=70): cv.positive_int,

vol.Optional(ATTR_CROP_X, default=0): cv.positive_int,
vol.Optional(ATTR_CROP_Y, default=0): cv.positive_int,
vol.Optional(ATTR_CROP_W, default=0): cv.positive_int,
vol.Optional(ATTR_CROP_H, default=0): cv.positive_int,

}
),
cv.make_entity_service_schema({
**LIGHT_TURN_ON_SCHEMA,
vol.Exclusive(ATTR_PATH, "ambient_extractor"): cv.isfile,
vol.Exclusive(ATTR_URL, "ambient_extractor"): cv.url,
vol.Optional(ATTR_COLOR_TEMPERATURE, default=False): cv.positive_int,
vol.Optional(ATTR_BRIGHTNESS_AUTO, default=False): cv.boolean,
vol.Optional(ATTR_BRIGHTNESS_MODE, default="mean"): cv.string,
vol.Optional(ATTR_BRIGHTNESS_MIN, default=2): cv.positive_int,
vol.Optional(ATTR_BRIGHTNESS_MAX, default=70): cv.positive_int,
vol.Optional(ATTR_CROP_X, default=0): cv.positive_int,
vol.Optional(ATTR_CROP_Y, default=0): cv.positive_int,
vol.Optional(ATTR_CROP_W, default=0): cv.positive_int,
vol.Optional(ATTR_CROP_H, default=0): cv.positive_int,
}),
)


def _get_file(file_path):
"""Get a PIL acceptable input file reference.
Allows us to mock patch during testing to make BytesIO stream.
"""
return file_path


def _get_color_from_image(im) -> tuple:
file_handler = TemporaryFile()
im.save(file_handler, "PNG")
return _get_color_from_file(file_handler)


def _get_color_from_file(file_handler) -> tuple:
"""Given an image file, extract the predominant color from it."""
color_thief = ColorThief(file_handler)

# get_color returns a SINGLE RGB value for the given image
color = color_thief.get_color(quality=1)
_LOGGER.debug("Extracted RGB color %s from image", color)
return color


def _get_cropped_image(file_handler, crop_area):
im = Image.open(file_handler)
if crop_area['active']:
im_width, im_height = im.size
im = im.crop((
math.floor(im_width / 100 * crop_area['x']),
math.floor(im_height / 100 * crop_area['y']),
math.floor(im_width / 100 * (crop_area['x'] + crop_area['w'])),
math.floor(im_width / 100 * (crop_area['y'] + crop_area['h'])),
))
return im


def _apply_color_temperature(color, color_temperature) -> tuple:
# Do not touch the default
if color_temperature == 6700:
return color

r, g, b = color
kelvin_table = {
1000: (255,56,0),
1500: (255,109,0),
2000: (255,137,18),
2500: (255,161,72),
3000: (255,180,107),
3500: (255,196,137),
4000: (255,209,163),
4500: (255,219,186),
5000: (255,228,206),
5500: (255,236,224),
6000: (255,243,239),
6500: (255,249,253),
7000: (245,243,255),
7500: (235,238,255),
8000: (227,233,255),
8500: (220,229,255),
9000: (214,225,255),
9500: (208,222,255),
10000: (204,219,255)}
cr, cg, cb = kelvin_table[color_temperature]
return (
r * (cr / 255.0),
g * (cg / 255.0),
b * (cb / 255.0)
)


def _get_brightness(im, br_mode, color):
if br_mode == "dominant":
r, g, b = color
return (r + g + b) / 3

if br_mode == "natural":
stat = ImageStat.Stat(im)
r,g,b = stat.mean
return math.sqrt(0.241*(r**2) + 0.691*(g**2) + 0.068*(b**2))

if br_mode == "rms":
stat = ImageStat.Stat(im.convert('L'))
return stat.rms[0]

# mean
stat = ImageStat.Stat(im.convert('L'))
return stat.mean[0]


async def async_setup(hass: HomeAssistant, hass_config: ConfigType) -> bool:
"""Set up services for ambient_extractor integration."""

async def async_handle_service(service_call: ServiceCall) -> None:
"""Decide which ambient_extractor method to call based on service."""
service_data = dict(service_call.data)

br_min = 2
Expand Down Expand Up @@ -195,9 +94,12 @@ async def async_handle_service(service_call: ServiceCall) -> None:
if ATTR_CROP_H in service_data:
crop_area['h'] = service_data.pop(ATTR_CROP_H)

color_temperature = 6700
color_temperature = 6600
if ATTR_COLOR_TEMPERATURE in service_data:
color_temperature = service_data.pop(ATTR_COLOR_TEMPERATURE)
color_temperature = 1000 if color_temperature < 1000 \
else 40000 if color_temperature > 40000 \
else color_temperature

# Don't crop if height or width == 0
if crop_area['w'] > 0 and crop_area['h'] > 0:
Expand Down Expand Up @@ -227,33 +129,22 @@ async def async_handle_service(service_call: ServiceCall) -> None:
brightness = colorset["brightness"]

except UnidentifiedImageError as ex:
_LOGGER.error(
"Bad image from %s '%s' provided, are you sure it's an image? %s",
image_type, # pylint: disable=used-before-assignment
image_reference,
ex,
)
_LOGGER.error("Bad image from %s '%s' provided. %s", image_type, image_reference, ex)
return

if color:
color = _apply_color_temperature(color, color_temperature)
color = apply_color_temperature(color, color_temperature)
service_data[ATTR_RGB_COLOR] = color

if brightness:
"""Apply min and max brightness"""
if br_min >= br_max:
effective_brightness = br_min
else:
effective_brightness = br_min + ( (brightness / 255) * (br_max - br_min) )

service_data[ATTR_BRIGHTNESS] = effective_brightness
service_data[ATTR_BRIGHTNESS] = br_min if br_min >= br_max \
else br_min + ((brightness / 255) * (br_max - br_min))

if color or brightness:
await hass.services.async_call(
LIGHT_DOMAIN, LIGHT_SERVICE_TURN_ON, service_data, blocking=True
)


hass.services.async_register(
DOMAIN,
SERVICE_TURN_ON,
Expand All @@ -264,37 +155,25 @@ async def async_handle_service(service_call: ServiceCall) -> None:
async def async_extract_color_from_url(url, check_brightness, br_mode, crop_area):
"""Handle call for URL based image."""
if not hass.config.is_allowed_external_url(url):
_LOGGER.error(
"External URL '%s' is not allowed, please add to 'allowlist_external_urls'",
url,
)
_LOGGER.error("External URL '%s' is not allowed, please add to 'allowlist_external_urls'", url)
return None

_LOGGER.debug("Getting predominant RGB from image URL '%s'", url)

# Download the image into a buffer for ColorThief to check against
try:
session = aiohttp_client.async_get_clientsession(hass)

async with async_timeout.timeout(10):
response = await session.get(url)

except (asyncio.TimeoutError, aiohttp.ClientError) as err:
_LOGGER.error("Failed to get ColorThief image due to HTTPError: %s", err)
_LOGGER.error("Failed to get image due to HTTPError: %s", err)
return None

content = await response.content.read()
with io.BytesIO(content) as _file:
_file.name = "ambient_extractor.jpg"
_file.seek(0)

im = _get_cropped_image(_file, crop_area)

color = _get_color_from_image(im) if crop_area['active'] else _get_color_from_file(_file)
brightness = 0
if check_brightness:
brightness = _get_brightness(im, br_mode, color)

im = get_cropped_image(_file, crop_area)
color = get_color_from_image(im) if crop_area['active'] else get_color_from_file(_file)
brightness = get_brightness(im, br_mode, color) if check_brightness else 0
return {
"color": color,
"brightness": brightness
Expand All @@ -303,21 +182,13 @@ async def async_extract_color_from_url(url, check_brightness, br_mode, crop_area
def extract_color_from_path(file_path, check_brightness, br_mode, crop_area):
"""Handle call for local file based image."""
if not hass.config.is_allowed_path(file_path):
_LOGGER.error(
"File path '%s' is not allowed, please add to 'allowlist_external_dirs'",
file_path,
)
_LOGGER.error("File path '%s' is not allowed, please add to 'allowlist_external_dirs'", file_path)
return None

_LOGGER.debug("Getting predominant RGB from file path '%s'", file_path)
_file = _get_file(file_path)
im = _get_cropped_image(_file, crop_area)
color = _get_color_from_image(im) if crop_area['active'] else _get_color_from_file(_file)

brightness = 0
if check_brightness:
brightness = _get_brightness(im, br_mode, color)

_file = get_file(file_path)
im = get_cropped_image(_file, crop_area)
color = get_color_from_image(im) if crop_area['active'] else get_color_from_file(_file)
brightness = get_brightness(im, br_mode, color) if check_brightness else 0
return {
"color": color,
"brightness": brightness
Expand Down
25 changes: 25 additions & 0 deletions custom_components/ambient_extractor/color_temperature.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import math


# @see https://tannerhelland.com/2012/09/18/convert-temperature-rgb-algorithm-code.html
def apply_color_temperature(color, color_temperature) -> tuple:
if color_temperature == 6600:
return color

factor_red = 255 if color_temperature <= 6600 \
else 329.698727446 * ((color_temperature / 100 - 60) ** -0.1332047592)
factor_green = 99.4708025861 * math.log(color_temperature / 100) - 161.1195681661 if color_temperature <= 6600 \
else 288.1221695283 * ((color_temperature / 100 - 60) ** -0.0755148492)
factor_blue = 255 if color_temperature >= 6600 else 0 if color_temperature <= 1900 \
else 138.5177312231 * math.log(color_temperature / 100 - 10) - 305.0447927307

factor_red = 0 if factor_red < 0 else 255 if factor_red > 255 else factor_red
factor_green = 0 if factor_green < 0 else 255 if factor_green > 255 else factor_green
factor_blue = 0 if factor_blue < 0 else 255 if factor_blue > 255 else factor_blue

r, g, b = color
return (
r * (factor_red / 255.0),
g * (factor_green / 255.0),
b * (factor_blue / 255.0)
)
21 changes: 21 additions & 0 deletions custom_components/ambient_extractor/extract_brightness.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from PIL import ImageStat
import math


def get_brightness(im, br_mode, color):
if br_mode == "dominant":
r, g, b = color
return (r + g + b) / 3

if br_mode == "natural":
stat = ImageStat.Stat(im)
r, g, b = stat.mean
return math.sqrt(0.241 * (r ** 2) + 0.691 * (g ** 2) + 0.068 * (b ** 2))

if br_mode == "rms":
stat = ImageStat.Stat(im.convert('L'))
return stat.rms[0]

# mean
stat = ImageStat.Stat(im.convert('L'))
return stat.mean[0]
Loading

0 comments on commit 459b874

Please sign in to comment.