from __future__ import annotations import hashlib import io import logging from pathlib import Path import re from PIL import Image, UnidentifiedImageError from esphome import core, external_files import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import ( CONF_DITHER, CONF_FILE, CONF_ICON, CONF_ID, CONF_PATH, CONF_RAW_DATA_ID, CONF_RESIZE, CONF_SOURCE, CONF_TYPE, CONF_URL, ) from esphome.core import CORE, HexInt _LOGGER = logging.getLogger(__name__) DOMAIN = "image" DEPENDENCIES = ["display"] image_ns = cg.esphome_ns.namespace("image") ImageType = image_ns.enum("ImageType") CONF_OPAQUE = "opaque" CONF_CHROMA_KEY = "chroma_key" CONF_ALPHA_CHANNEL = "alpha_channel" CONF_INVERT_ALPHA = "invert_alpha" TRANSPARENCY_TYPES = ( CONF_OPAQUE, CONF_CHROMA_KEY, CONF_ALPHA_CHANNEL, ) def get_image_type_enum(type): return getattr(ImageType, f"IMAGE_TYPE_{type.upper()}") def get_transparency_enum(transparency): return getattr(TransparencyType, f"TRANSPARENCY_{transparency.upper()}") class ImageEncoder: """ Superclass of image type encoders """ # Control which transparency options are available for a given type allow_config = {CONF_ALPHA_CHANNEL, CONF_CHROMA_KEY, CONF_OPAQUE} # All imageencoder types are valid @staticmethod def validate(value): return value def __init__(self, width, height, transparency, dither, invert_alpha): """ :param width: The image width in pixels :param height: The image height in pixels :param transparency: Transparency type :param dither: Dither method :param invert_alpha: True if the alpha channel should be inverted; for monochrome formats inverts the colours. """ self.transparency = transparency self.width = width self.height = height self.data = [0 for _ in range(width * height)] self.dither = dither self.index = 0 self.invert_alpha = invert_alpha self.path = "" def convert(self, image, path): """ Convert the image format :param image: Input image :param path: Path to the image file :return: converted image """ return image def encode(self, pixel): """ Encode a single pixel """ def end_row(self): """ Marks the end of a pixel row :return: """ def is_alpha_only(image: Image): """ Check if an image (assumed to be RGBA) is only alpha """ # Any alpha data? if image.split()[-1].getextrema()[0] == 0xFF: return False return all(b.getextrema()[1] == 0 for b in image.split()[:-1]) class ImageBinary(ImageEncoder): allow_config = {CONF_OPAQUE, CONF_INVERT_ALPHA, CONF_CHROMA_KEY} def __init__(self, width, height, transparency, dither, invert_alpha): self.width8 = (width + 7) // 8 super().__init__(self.width8, height, transparency, dither, invert_alpha) self.bitno = 0 def convert(self, image, path): if is_alpha_only(image): image = image.split()[-1] return image.convert("1", dither=self.dither) def encode(self, pixel): if self.invert_alpha: pixel = not pixel if pixel: self.data[self.index] |= 0x80 >> (self.bitno % 8) self.bitno += 1 if self.bitno == 8: self.bitno = 0 self.index += 1 def end_row(self): """ Pad rows to a byte boundary """ if self.bitno != 0: self.bitno = 0 self.index += 1 class ImageGrayscale(ImageEncoder): allow_config = {CONF_ALPHA_CHANNEL, CONF_CHROMA_KEY, CONF_INVERT_ALPHA, CONF_OPAQUE} def convert(self, image, path): if is_alpha_only(image): if self.transparency != CONF_ALPHA_CHANNEL: _LOGGER.warning( "Grayscale image %s is alpha only, but transparency is set to %s", path, self.transparency, ) self.transparency = CONF_ALPHA_CHANNEL image = image.split()[-1] return image.convert("LA") def encode(self, pixel): b, a = pixel if self.transparency == CONF_CHROMA_KEY: if b == 1: b = 0 if a != 0xFF: b = 1 if self.invert_alpha: b ^= 0xFF if self.transparency == CONF_ALPHA_CHANNEL: if a != 0xFF: b = a self.data[self.index] = b self.index += 1 class ImageRGB565(ImageEncoder): def __init__(self, width, height, transparency, dither, invert_alpha): stride = 3 if transparency == CONF_ALPHA_CHANNEL else 2 super().__init__( width * stride, height, transparency, dither, invert_alpha, ) def convert(self, image, path): return image.convert("RGBA") def encode(self, pixel): r, g, b, a = pixel r = r >> 3 g = g >> 2 b = b >> 3 if self.transparency == CONF_CHROMA_KEY: if r == 0 and g == 1 and b == 0: g = 0 elif a < 128: r = 0 g = 1 b = 0 rgb = (r << 11) | (g << 5) | b self.data[self.index] = rgb >> 8 self.index += 1 self.data[self.index] = rgb & 0xFF self.index += 1 if self.transparency == CONF_ALPHA_CHANNEL: if self.invert_alpha: a ^= 0xFF self.data[self.index] = a self.index += 1 class ImageRGB(ImageEncoder): def __init__(self, width, height, transparency, dither, invert_alpha): stride = 4 if transparency == CONF_ALPHA_CHANNEL else 3 super().__init__( width * stride, height, transparency, dither, invert_alpha, ) def convert(self, image, path): return image.convert("RGBA") def encode(self, pixel): r, g, b, a = pixel if self.transparency == CONF_CHROMA_KEY: if r == 0 and g == 1 and b == 0: g = 0 elif a < 128: r = 0 g = 1 b = 0 self.data[self.index] = r self.index += 1 self.data[self.index] = g self.index += 1 self.data[self.index] = b self.index += 1 if self.transparency == CONF_ALPHA_CHANNEL: if self.invert_alpha: a ^= 0xFF self.data[self.index] = a self.index += 1 class ReplaceWith: """ Placeholder class to provide feedback on deprecated features """ allow_config = {CONF_ALPHA_CHANNEL, CONF_CHROMA_KEY, CONF_OPAQUE} def __init__(self, replace_with): self.replace_with = replace_with def validate(self, value): raise cv.Invalid( f"Image type {value} is removed; replace with {self.replace_with}" ) IMAGE_TYPE = { "BINARY": ImageBinary, "GRAYSCALE": ImageGrayscale, "RGB565": ImageRGB565, "RGB": ImageRGB, "TRANSPARENT_BINARY": ReplaceWith( "'type: BINARY' and 'use_transparency: chroma_key'" ), "RGB24": ReplaceWith("'type: RGB'"), "RGBA": ReplaceWith("'type: RGB' and 'use_transparency: alpha_channel'"), } TransparencyType = image_ns.enum("TransparencyType") CONF_TRANSPARENCY = "transparency" # If the MDI file cannot be downloaded within this time, abort. IMAGE_DOWNLOAD_TIMEOUT = 30 # seconds SOURCE_LOCAL = "local" SOURCE_MDI = "mdi" SOURCE_WEB = "web" Image_ = image_ns.class_("Image") def compute_local_image_path(value) -> Path: url = value[CONF_URL] if isinstance(value, dict) else value h = hashlib.new("sha256") h.update(url.encode()) key = h.hexdigest()[:8] base_dir = external_files.compute_local_file_dir(DOMAIN) return base_dir / key def local_path(value): value = value[CONF_PATH] if isinstance(value, dict) else value return str(CORE.relative_config_path(value)) def download_file(url, path): external_files.download_content(url, path, IMAGE_DOWNLOAD_TIMEOUT) return str(path) def download_mdi(value): mdi_id = value[CONF_ICON] if isinstance(value, dict) else value base_dir = external_files.compute_local_file_dir(DOMAIN) / "mdi" path = base_dir / f"{mdi_id}.svg" url = f"https://raw.githubusercontent.com/Templarian/MaterialDesign/master/svg/{mdi_id}.svg" return download_file(url, path) def download_image(value): value = value[CONF_URL] if isinstance(value, dict) else value return download_file(value, compute_local_image_path(value)) def is_svg_file(file): if not file: return False with open(file, "rb") as f: return " 500 or height > 500): _LOGGER.warning( 'The image "%s" you requested is very big. Please consider' " using the resize parameter.", path, ) dither = ( Image.Dither.NONE if config[CONF_DITHER] == "NONE" else Image.Dither.FLOYDSTEINBERG ) type = config[CONF_TYPE] transparency = config[CONF_TRANSPARENCY] invert_alpha = config[CONF_INVERT_ALPHA] frame_count = 1 if all_frames: try: frame_count = image.n_frames except AttributeError: pass if frame_count <= 1: _LOGGER.warning("Image file %s has no animation frames", path) total_rows = height * frame_count encoder = IMAGE_TYPE[type](width, total_rows, transparency, dither, invert_alpha) for frame_index in range(frame_count): image.seek(frame_index) pixels = encoder.convert(image.resize((width, height)), path).getdata() for row in range(height): for col in range(width): encoder.encode(pixels[row * width + col]) encoder.end_row() rhs = [HexInt(x) for x in encoder.data] prog_arr = cg.progmem_array(config[CONF_RAW_DATA_ID], rhs) image_type = get_image_type_enum(type) trans_value = get_transparency_enum(encoder.transparency) return prog_arr, width, height, image_type, trans_value, frame_count async def to_code(config): if isinstance(config, list): for entry in config: await to_code(entry) elif CONF_ID not in config: for entry in config.values(): await to_code(entry) else: prog_arr, width, height, image_type, trans_value, _ = await write_image(config) cg.new_Pvariable( config[CONF_ID], prog_arr, width, height, image_type, trans_value )