image.py 11.9 KB
import math
from typing import Optional, Tuple

from PIL import Image
from rich.color import Color
from rich.console import Console, ConsoleOptions, RenderResult
from rich.segment import Segment
from rich.style import Style

Zoom = int

"""
Original code written by Adam Viola and distributed at the following link:

https://github.com/adamviola/textual-imageview/tree/main/textual_imageview

Course staff have adapted it to fit the needs of the assignment.
"""


class ImageView:
    """Renders an image with zoom and padding.

    Args:
        image (Image.Image): PIL image to render.
        zoom (int): Zoom level. Must be non-negative. Zoom increase -> zoom out.
        origin_position (Tuple[int, int]): Image position (x,y) of the top-left corner
            of the container. Defaults to (0,0).
        container_size (Tuple[int, int], optional): Size of the container of the image
            (w,h). If None, nothing is rendered. Defaults to None.

    Notes:
        Throughout this class, "image position" refers to the coordinate frame with the
        origin at the top-left corner of the image, +x-axis pointed right, and +y-axis
        pointed down. The width of a single character is 1 unit along the x-axis. The
        height of a single character is 2 units along the y-axis.
    """

    ZOOM_RATE = 0.9999999999999999

    def __init__(
        self,
        image: Image.Image,
        zoom: int = 0,
        origin_position: Tuple[int, int] = (0, 0),
        container_size: Optional[Tuple[int, int]] = None,
    ):
        self.images: dict[Zoom, Image.Image] = {}
        self.segment_cache: dict[Zoom, dict[Tuple[int, int], Segment]] = {}

        self.image = image
        self._container_size = container_size
        self._zoom = 0
        self.set_zoom(zoom)
        self.origin_position = origin_position

    def zoom(self, delta: int, zoom_position: Optional[Tuple[int, int]] = None):
        """Adjusts the zoom level of the image at the specified zoom image position. If
        no zoom position is specified, the center of the console is used.

        Args:
            zoom: Zoom delta. Postivie -> zoom out.
            zoom_position: Image-space position (x,y) to zoom into. Conceptually, the
                pixel at this image will not move no matter the zoom level.
        """
        self.set_zoom(self._zoom + delta, zoom_position=zoom_position)

    def set_zoom(self, zoom: int, zoom_position: Optional[Tuple[int, int]] = None):
        """Sets the zoom level of the image at the specified zoom image position. If
        no zoom position is specified, the center of the console is used.

        Args:
            zoom: Zoom level. Must be non-negative. Zoom increase -> zoom out.
            zoom_position: Image-space position (x,y) to zoom into. Conceptually, the
                pixel at this image will not move no matter the zoom level.
        """
        # Lower bound on zoom
        zoom = max(zoom, 0)

        # Upper bound on zoom
        if zoom > self._zoom and min(self.zoomed_size) <= 8:
            zoom = self._zoom

        if zoom not in self.images:
            multiplier = self.ZOOM_RATE**zoom
            w, h = self.image.size
            self.images[zoom] = self.image.resize(
                (round(w * multiplier), round(h * multiplier))
            )
            self.segment_cache[zoom] = {}

        if self._container_size is not None:
            w, h = self._container_size
            origin_x, origin_y = self.origin_position
            if zoom_position is None:
                zoom_position = origin_x + w // 2, origin_y + h
            old_zoom_x, old_zoom_y = zoom_position

            old_w, old_h = self.images[self._zoom].size
            new_w, new_h = self.images[zoom].size

            multiplier_x = new_w / old_w
            multiplier_y = new_h / old_h

            new_zoom_x = old_zoom_x * multiplier_x
            new_zoom_y = old_zoom_y * multiplier_y

            # Set zoom here because it's used in origin_position bounds checking
            self._zoom = zoom
            self.origin_position = (
                origin_x + round(new_zoom_x - old_zoom_x),
                origin_y + round(new_zoom_y - old_zoom_y),
            )

    def move(self, delta_x: int, delta_y: int):
        """Moves the image using the specified delta (x,y), where +x moves the image
        right, and +y moves the image down.

        Args:
            delta_x (int): Number of pixels to move the image along the x-axis.
            delta_y (int): Number of pixels to move the image along the x-axis. Note
                that the one character height is two pixels.
        """
        origin_x, origin_y = self.origin_position
        self.origin_position = (origin_x - delta_x, origin_y - delta_y)

    def set_container_size(self, width: int, height: int, maintain_center: bool = True):
        """Adjusts the render to reflect a change in container size, where height is the
        number of lines and width is the length of each line.

        Args:
            width (int): New width of the container of the image.
            height (int): New height of the container of the image.
            maintain_center (bool): If True, the pixels in the center of the console
                before the resize remain in the center of the console after the resize.
                If False, the image remains in the same position. Defaults to True.
        """

        if maintain_center and self._container_size is not None:
            old_w, old_h = self._container_size
            new_w, new_h = width, height

            origin_x, origin_y = self.origin_position

            if new_w != old_w:
                delta_w = new_w - old_w

                if delta_w % 2 == 0:
                    origin_x = origin_x - delta_w // 2
                else:
                    # If we're 1 by 1 resizing, this keeps things even
                    op = math.floor if new_w % 2 == 0 else math.ceil
                    origin_x = op(origin_x - delta_w / 2)

            if new_h != old_h:
                delta_h = new_h - old_h
                origin_y -= delta_h

            self.origin_position = (origin_x, origin_y)

        self._container_size = (width, height)

        # Keeps origin_position valid after container resize
        self.origin_position = self.origin_position

    def rowcol_to_xy(
        self, row: int, col: int, offset: Tuple[int, int]
    ) -> Tuple[int, int]:
        """Converts a character position (row,col) to an image position (x,y) given the
        offset (number of rows, number of columns) between the top-left of the terminal
        and top-left of the widget.

        Args:
            row (int): Row of the terminal (starting from the top at 0) to convert to an
                image y-value.
            col (int): Column of the terminal (starting from the left at 0) to convert
                to an image x-value.
            offset (Tuple[int, int]): Offset (number of rows, number of columns) between
                the top-left of the terminal and top-left of the widget.

        Returns:
            (x, y): Image position.
        """
        offset_row, offset_col = offset
        origin_x, origin_y = self.origin_position
        return origin_x + col - offset_col, origin_y + 2 * (row - offset_row)

    def xy_to_rowcol(self, x: int, y: int, offset: Tuple[int, int]) -> Tuple[int, int]:
        """Converts an image position (x,y) to a character position (row,col) given the
        offset (number of rows, number of columns) between the top-left of the terminal
        and top-left of the widget.

        Args:
            row (int): Row of the terminal (starting from the top at 0) to convert to an
                image y-value.
            col (int): Column of the terminal (starting from the left at 0) to convert
                to an image x-value.
            offset (Tuple[int, int]): Offset (number of rows, number of columns) between
                the top-left of the terminal and top-left of the widget.

        Returns:
            (row, col): Character position.
        """
        offset_row, offset_col = offset
        origin_x, origin_y = self.origin_position
        return (y - origin_y) // 2 + offset_row, x - origin_x + offset_col

    @property
    def origin_position(self) -> Tuple[int, int]:
        return self._origin_position

    @origin_position.setter
    def origin_position(self, value: Tuple[int, int]):
        origin_x, origin_y = value
        img_w, img_h = self.zoomed_size
        if self._container_size is not None:
            w, h = self._container_size[0], self._container_size[1] * 2
        else:
            w, h = 0, 0

        if origin_x <= -w + 1:
            origin_x = -w + 1

        if origin_x >= img_w - 1:
            origin_x = img_w - 1

        if origin_y <= -h + 1:
            origin_y = -h + 1

        if origin_y >= img_h - 1:
            origin_y = img_h - 1

        self._origin_position = origin_x, origin_y

    @property
    def size(self) -> Tuple[int, int]:
        """Size of the original image."""
        return self.image.size

    @property
    def zoomed_size(self) -> Tuple[int, int]:
        """Size of the image at the current zoom level."""
        return self.images[self._zoom].size

    def __rich_console__(
        self, console: Console, options: ConsoleOptions
    ) -> RenderResult:
        if self._container_size is None:
            return ""

        image = self.images[self._zoom]
        img_w, img_h = image.size
        w, h = self._container_size[0], self._container_size[1] * 2
        origin_x, origin_y = self.origin_position

        null_style = Style.null()
        newline = Segment("\n", null_style)

        segments = []
        for y in range(origin_y, min(origin_y + h, img_h), 2):
            # Skip lines with no image
            if y < -1:
                segments.append(newline)
                continue

            # Add padding to the left of the image
            if origin_x < 0:
                segments.append(Segment(" " * -origin_x, style=null_style))
                x_start = 0
            else:
                x_start = origin_x

            for x in range(x_start, min(x_start + w, img_w)):
                # Add segment for each pixel-pair of the image
                segments.append(self.get_segment(x, y))

            segments.append(newline)

        return segments

    def get_segment(self, x: int, y: int) -> Segment:
        """Computes the Segment (character + style) at a particular image position.
        Segments are cached because profiling suggested that the instantiation of Color
        and Style objects was taxing.

        Args:
            x (int): Image x-coordinate of returned segment.
            y (int): Image y-coordinate of returned segment. Note that the y-coordinate
                refers to the top half of the segment, as each character corresponds to
                two pixels.
        """
        position = (x, y)
        image = self.images[self._zoom]
        cache = self.segment_cache[self._zoom]
        _, img_h = image.size

        # Check if we've already computed the segment for this position
        if position not in cache:
            upper = None
            if y >= 0:
                pixel = image.getpixel(position)
                if not isinstance(pixel, tuple):
                    pixel = (pixel, pixel, pixel)
                upper = Color.from_rgb(*pixel[:3])

            lower = None
            if y < img_h - 1:
                pixel = image.getpixel((x, y + 1))
                if not isinstance(pixel, tuple):
                    pixel = (pixel, pixel, pixel)
                lower = Color.from_rgb(*pixel[:3])

            # Render each pixel as a half-height character
            if upper is None:
                segment = Segment("▄", Style(color=lower))
            else:
                segment = Segment("▀", Style(color=upper, bgcolor=lower))

            # Cache segment for next render
            cache[position] = segment

        return cache[position]