Source code for honeybee_vtk.image_processing

"""Functionality for image processing."""


import math
import tempfile
import cv2
import shutil

from pathlib import Path
from ladybug.dt import DateTime
from typing import List, Tuple
from PIL import Image, ImageDraw, ImageFont


def _transparent_background(image: Image.Image, tolerance=200) -> Image.Image:
    """Make a transparent background for an image.

    Args:
        image: An Image object.
        tolerance: A max distance for the color from 255, 255, 255. Default is set
            to 200.

    Returns:
        An Image object with a fully transparent background.
    """
    image = image.convert("RGBA")
    image_data = image.getdata()

    new_image_data = []

    for pixel in image_data:
        dis = math.sqrt(
            (255 - pixel[0]) ** 2 + (255 - pixel[1]) ** 2 + (255 - pixel[2]) ** 2
        )
        if dis < tolerance:
            new_image_data.append((255, 255, 255, 0))
        else:
            new_image_data.append(pixel)

    image.putdata(new_image_data)
    return image


def _composite(image_path_1: Path, image_path_2: Path,
               temp_folder: Path,
               target_folder: Path, name: str):
    """Write a composite png from a two images.

    Args:
        image_path_1: A pathlib.Path object for the first image.
        image_path_2: A pathlib.Path object for the second image.
        temp_folder: A pathlib.Path object for the temporary folder.
        target_folder: A pathlib.Path object for the folder to write the composite
            image to.
        name: A string for the name of the composite image.
    """
    image_1 = Image.open(image_path_1)
    image_2 = Image.open(image_path_2)
    composite = Image.alpha_composite(image_2, image_1)
    composite.save(f'{temp_folder}/{name}_composite.png', 'PNG')
    composite.save(f'{target_folder}/{image_path_1.stem}.png', 'PNG')


def _composite_folder(temp_folder: Path, images_folder: Path,
                      number_of_images: int) -> Path:
    """Write a folder of composite images.

    Args:
        temp_folder: A pathlib.Path object for the temporary folder.
        images_folder: A pathlib.Path object to the folder to read images from.
        number_of_images: An integer for the number of images in the folder.

    Returns:
        A pathlib.Path object for the folder of the composite images.
    """

    composite_images_folder = temp_folder.joinpath('composite')
    composite_images_folder.mkdir()

    temp_composite_folder = composite_images_folder.joinpath('temp')
    temp_composite_folder.mkdir()

    image_paths = _files_in_order(temp_folder, images_folder.stem, number_of_images)

    image_path_0 = image_paths[0]
    first_composite = Image.open(image_path_0)
    first_composite.save(f'{temp_composite_folder}/0_composite.png', 'PNG')
    first_composite.save(f'{composite_images_folder}/{image_path_0.stem}.png', 'PNG')

    for i in range(1, len(image_paths)):
        image_path_1 = image_paths[i]
        image_path_2 = Path(f'{temp_composite_folder}/{i-1}_composite.png')
        _composite(image_path_1, image_path_2,
                   temp_composite_folder, composite_images_folder, str(i))

    shutil.rmtree(temp_composite_folder.as_posix())

    return composite_images_folder


def _translucent(image: Image.Image, transparency: float):
    """Set uniform transparency for an image.

    Args:
        image: An Image object.
        transparency: An integer between 0 and 1. 0 is fully transparent and 1 is
            fully opaque.

    Returns:
        An Image object with a uniform transparency.
    """
    assert 0 <= transparency <= 1, 'Transparency must be a between 0 and 1 inclusive.'

    image_rgba = image.copy()
    image_rgba.putalpha(round(transparency * 255))
    return image_rgba


def _gif(temp_folder: Path, images_folder: Path, target_folder: Path,
         number_of_images: int, gif_name: str,
         gif_duration: int, gif_loop_count: int, linger_last_frame: int = 30) -> None:
    """Write a gif from a folder of images.

    Args:
        temp_folder: A pathlib.Path object for the temporary folder.
        images_folder: A pathlib.Path object to the folder to read images from.
        target_folder: A pathlib.Path object for the folder to write the GIF to.
        number_of_images: An integer for the number of images in the folder.
        gif_name: A string for the name of the GIF.
        gif_duration: An integer for the duration of each frame in the GIF in 
            milliseconds.
        gif_loop_count: An integer for the number of times to loop the GIF.
        linger_last_frame: An integer that will make the last frame linger for longer
            than the duration. If set to 0, the last frame will not linger. Setting it
            to 3 will make the last frame linger for 3 times the duration. Defaults to
            3.
    """

    image_paths = _files_in_order(temp_folder, images_folder.stem, number_of_images)
    images = [Image.open(image_path) for image_path in image_paths]
    image = images[0]
    rest_of_images = images[1:] + [images[-1]] * linger_last_frame
    image.save(f'{target_folder}/{gif_name}.gif', save_all=True,
               append_images=rest_of_images, duration=gif_duration, loop=gif_loop_count,
               transparency=0, format='GIF', disposal=2)


def _blended_image(image_paths: List[Path],
                   target_folder: Path, name: str) -> None:
    """Write a blended image.

    Args:
        image_paths: A list of pathlib.Path objects for the images to blend into one.
        target_folder: A pathlib.Path object for the folder to write the blended image 
            to.
        number: A text representing the name of the image to write.
    """
    image_data = []
    for my_file in image_paths:
        this_image = cv2.imread(my_file.as_posix(), cv2.IMREAD_UNCHANGED)
        image_data.append(this_image)

    dst = image_data[0]
    for i in range(len(image_data)):
        if i == 0:
            pass
        else:
            alpha = 1.0/(i + 1)
            beta = 1.0 - alpha
            dst = cv2.addWeighted(image_data[i], alpha, dst, beta, 0.0)

    cv2.imwrite(f'{target_folder}/{name}.png', dst)


def _blended_folder(temp_folder: Path, images_folder: Path,
                    number_of_images: int) -> Path:
    """Write a folder of blended images.

    Args:
        temp_folder: A pathlib.Path object for the temporary folder.
        images_folder: A pathlib.Path object to the folder to read images from.
        number_of_images: An integer for the number of images in the folder.

    Returns:
        A pathlib.Path object for the folder of blended images.
    """

    blended_images_folder = temp_folder.joinpath('blended')
    blended_images_folder.mkdir()

    image_paths = _files_in_order(temp_folder, images_folder.stem, number_of_images)

    for i in range(1, len(image_paths)+1):
        _blended_image(image_paths[:i], blended_images_folder, str(i-1))

    return blended_images_folder


def _hoy_to_text(image_path: Path) -> str:
    """Convert a hoy image to text.

    This function reads the HOY from the image name and converts it to a text.

    Args:
        image_path: A path to a hoy image.

    Returns:
        A text that is the HOY converted into a human readable form.
    """
    hoy = float(image_path.stem.split('_')[0])
    text = DateTime.from_hoy(hoy).to_simple_string()
    updated_text = ''
    for count, item in enumerate(list(text.split('_'))):
        if not count == 2:
            updated_text += item + ' '
        else:
            updated_text += item + ':'
    return updated_text.strip()


def _annotate_image(image: Image.Image, text: str, text_height: int):
    """Add text to an image object.

    Args:
        image: An Image object.
        text: A string.
        text_height: An integer.

    Returns:
        An Image object with text added.
    """

    width, height = image.size
    image_draw = ImageDraw.Draw(image)

    try:
        fnt = ImageFont.truetype('arial.ttf', text_height)
    except OSError:
        try:
            # ubuntu
            fnt = ImageFont.truetype('DejaVuSans.ttf', text_height)
        except OSError:
            # load a default font
            print('Failed to find the font. Loading the default font.')
            fnt = ImageFont.load_default()

    padding = 5
    x_1 = width - (6.5 * text_height)
    x_2 = width
    y_1 = height - text_height - padding
    y_2 = height - padding

    image_draw.rectangle((x_1 - padding, y_1, x_2, y_2), fill='white')
    image_draw.text((x_1, y_1), text, font=fnt, fill='black')
    return image


def _annotated_folder(temp_folder: Path, images_folder: Path,
                      text_on_images: List[str],
                      text_height: int,
                      number_of_images: int) -> Path:
    """Annotate images with text.

    This function reads all the images in the images_folder and annotates them with
    the text in text_on_images.

    Args:
        temp_folder: A path to the temp folder.
        images_folder: A path to the folder from which images are read.
        text_on_images: A list of strings to be added to the images as annotation.
        text_height: The height of the text.
        number_of_images: The number of images in the images_folder.

    Returns:
        A path to the folder with annotated images.
    """
    assert len(text_on_images) == len(list(images_folder.iterdir())),\
        f'Number of images in {images_folder} does not match number of image names.'

    annotated_images_folder = temp_folder.joinpath('annotated')
    annotated_images_folder.mkdir()

    image_paths = _files_in_order(temp_folder, images_folder.stem, number_of_images)

    for count, image_path in enumerate(image_paths):
        image = Image.open(image_path)
        image = _annotate_image(image, text_on_images[count], text_height)
        image.save(f'{annotated_images_folder}/{image_path.stem}.png', 'PNG')

    return annotated_images_folder


def _rename_image(image_path: Path, target_folder: Path,
                  image_name: str) -> None:
    """Rename an image.

    Args:
        image_path: A pathlib.Path object for path to the image.
        target_folder: A pathlib.Path object for the target folder where the renamed
            image will be written.
        image_name: A string to be used as the name of the image.
    """
    image = Image.open(image_path)
    image.save(f'{target_folder}/{image_name}.png', 'PNG')


def _serialized_images_and_timestamps(grid_folder: Path,
                                      temp_folder: Path) -> Tuple[Path,
                                                                  List[str]]:
    """Write serialized images and get a list of timestamp strings.

    We are iterating through all the time period folders and pulling images out of them.
    We are renaming these images based on the image count before writing to a folder
    named 'serialized'. This helps us down the line when we need to use the images in
    order. While translating, we're also generating the time step annotation to put
    on the images later.

    Args:
        grid_folder: The folder containing the time period folders.
        temp_folder: The folder to write the serialized images to.

    Returns:
        A tuple of two items:

        -   The path to the folder containing the serialized images.
        -   A list of timestamps as text strings.
    """
    time_stamps: List[str] = []
    serialized_images_folder = temp_folder.joinpath('serialized')
    serialized_images_folder.mkdir()

    dec, mar, jun = [], [], []

    for image_path in list(grid_folder.iterdir()):
        if 1416 <= float(image_path.stem.split('_')[0]) <= 2156:
            mar.append(image_path)
        elif 3624 <= float(image_path.stem.split('_')[0]) <= 4343:
            jun.append(image_path)
        else:
            dec.append(image_path)

    grouped_images: List[List[Path]] = [dec, mar, jun]

    image_count = 0
    for month in grouped_images:
        for image_path in month:
            _rename_image(image_path, serialized_images_folder, str(image_count))
            time_stamps.append(_hoy_to_text(image_path))
            image_count += 1

    return serialized_images_folder, time_stamps


def _files_in_order(temp_folder: Path, parent: str,
                    number_of_images: int) -> List[Path]:
    """Return a list of file paths in order.

    This function helps you read files in order from a folder. When ordering the strings
    11 will come after 1 in stead of 2. This function helps you bypass that and read
    the files in order.

    Args:
        temp_folder: Path to the temp folder.
        parent: The name of the folder to read from.

    Returns:
        A list of file paths in order.
    """
    return [temp_folder.joinpath(f'{parent}/{i}.png') for i in range(number_of_images)]


def _transparent_translucent(temp_folder: Path, images_folder: Path,
                             translucency: bool = True) -> Path:
    """Apply transparency to images and make the background translucent.

    Args:
        temp_folder: Path to the temp folder.
        images_folder: The folder containing the images to apply transparency to.
        translucency: A boolean to determine if the transparency should be applied.
            Defaults to True.

    Returns:
        The path to the folder containing the images with transparency applied and 
        background made transparent.
    """
    trans_folder = temp_folder.joinpath('trans')
    trans_folder.mkdir()

    if translucency:
        for image_path in images_folder.iterdir():
            image = Image.open(image_path.as_posix())
            image = _translucent(image, 0.5)
            image = _transparent_background(image)
            image.save(f'{trans_folder}/{image_path.stem}.png', 'PNG')
    else:
        for image_path in images_folder.iterdir():
            image = Image.open(image_path.as_posix())
            image = _transparent_background(image)
            image.save(f'{trans_folder}/{image_path.stem}.png', 'PNG')

    return trans_folder


[docs]def write_gif(time_step_images_path: str, target_path: str = '.', gradient_transparency: bool = False, duration: int = 1000, loop_count: int = 0, linger_last_frame: int = 3, text_height: int = 20) -> str: """Export a GIF from a time step images. This function will generate one folder for each grid found in the model. Args: time_step_images_path: The path to the folder containing the images. for time steps. target_path: The path to the folder to write the GIF to. Defaults to current working directory. gradient_transparency: Whether to use a gradient transparency. or not. If chosen a gradient of transparency will be used. Which will make the image in the back more transparent compared to the image in the front. Defaults to False which will use a flat transparency. which means the all images will have same amount of transparency. duration: The duration of the gif in milliseconds. Defaults to 1000. loop_count: The number of times to loop the gif. Defaults to 0 which will loop infinitely. linger_last_frame: An integer that will make the last frame linger for longer than the duration. If set to 0, the last frame will not linger. Setting it to 3 will make the last frame linger for 3 times the duration. Defaults to 3. text_height: An integer to set the text height in pixels. Default is set to 20. Returns: The path to the folder where GIFs are exported. """ time_step_images_folder = Path(time_step_images_path) assert time_step_images_folder.is_dir(), 'The images folder must be a directory.' assert len(list(time_step_images_folder.glob('*'))) > 0, 'The images folder must' ' not be empty.' target_folder = Path(target_path) if not target_folder.exists(): target_folder.mkdir(parents=True, exist_ok=True) assert target_folder.is_dir(), 'The target folder must be a directory.' for grid_folder in time_step_images_folder.iterdir(): temp_folder = Path(tempfile.mkdtemp()) serialized_images_folder, time_stamp_strings = _serialized_images_and_timestamps( grid_folder, temp_folder) if gradient_transparency: blended_folder = _blended_folder(temp_folder, serialized_images_folder, len(time_stamp_strings)) gif_images_folder = _transparent_translucent(temp_folder, blended_folder, translucency=False) else: trans_folder = _transparent_translucent(temp_folder, serialized_images_folder) gif_images_folder = _composite_folder(temp_folder, trans_folder, len(time_stamp_strings)) annotated_folder = _annotated_folder(temp_folder, gif_images_folder, time_stamp_strings, text_height, len(time_stamp_strings)) _gif(temp_folder, annotated_folder, target_folder, len(time_stamp_strings), grid_folder.stem, duration, loop_count, linger_last_frame) try: shutil.rmtree(temp_folder) except Exception: pass return target_folder.as_posix()
[docs]def write_transparent_images(time_step_images_path: str, target_path: str = '.', transparency: float = 0.5) -> str: """Write a transparent image for each of the time step images. This function will generate one folder for each grid found in the model. Args: time_step_images_path: The path to the folder containing the images. for time steps. target_path: The path to the folder where the transparent images will be written to. Defaults to current working directory. transparency: The transparency value to use. Acceptable values are decimal point numbers between 0 and 1 inclusive. 0 is completely transparent and 1 is completely opaque. Defaults to 0.5. Returns: The path to the folder where the transparent images are written to. """ time_step_images_folder = Path(time_step_images_path) assert time_step_images_folder.is_dir(), 'The images folder must be a directory.' \ f' Make sure the path is correct. Given path is {time_step_images_path}' assert len(list(time_step_images_folder.glob('*'))) > 0, 'The images folder must' ' not be empty.' target_folder = Path(target_path) if not target_folder.exists(): target_folder.mkdir(parents=True, exist_ok=True) assert target_folder.is_dir(), 'The target folder must be a directory.' for grid_folder in time_step_images_folder.iterdir(): grid_images_folder = target_folder.joinpath(f'{grid_folder.stem}_trans_images') if grid_images_folder.is_dir(): shutil.rmtree(grid_images_folder) grid_images_folder.mkdir() image_count = 0 for image_path in grid_folder.iterdir(): image = Image.open(image_path.as_posix()) image = _translucent(image, transparency) image = _transparent_background(image) text = _hoy_to_text(image_path) image = _annotate_image(image, text, 20) image.save(f'{grid_images_folder}/{image_count}.png', 'PNG') image_count += 1 return target_folder.as_posix()