Source code for psychos.visual.units

"""psychos.visual.units: Module with unit systems for converting between coordinate systems."""

from abc import ABC, abstractmethod
from typing import Tuple, Dict, Type, TYPE_CHECKING, Union, Optional
import re

from ..types import UnitTransformation, UnitType
from ..utils import register

if TYPE_CHECKING:
    from psychos.visual.window import Window

__all__ = [
    "Unit",
    "PixelUnits",
    "NormalizedUnits",
    "PercentageUnit",
    "VWUnit",
    "VHUnit",
    "CMUnit",
    "VDUnit",
    "MMUnit",
    "PTUnit",
    "DegUnit",
    "INUnit",
    "parse_width",
    "parse_height",
]

UNIT_SYSTEMS: Dict["UnitTransformation", Type["Unit"]] = {}


[docs] class Unit(ABC): """ Abstract base class for different unit systems that transform normalized or other unit types into pixel values. """
[docs] def __init__(self, window: "Window"): self.window = window
[docs] @classmethod def from_name(cls, name: Union["UnitType", "Unit"], window: "Window") -> "Unit": """ Instantiate a unit system class by name or return the instance if already provided. Parameters ---------- name : Union[str, Unit] The name of the unit system or an instance of Unit. window : Window The window object, used to get the size for the transformation. Returns ------- Unit An instance of the unit system class or the provided Unit instance. """ if isinstance(name, Unit): return name # If already an instance of Unit, return it directly unit_cls = UNIT_SYSTEMS.get(name) if unit_cls is None: raise ValueError( f"Unknown unit system: {name}. " f"Available systems: {list(UNIT_SYSTEMS.keys())}" ) return unit_cls(window=window)
def __call__( self, x: Union[float, int], y: Union[float, int], transformation: UnitTransformation = "transform", ) -> Tuple[Union[int, float], Union[int, float]]: """ Call method to dynamically apply a transformation based on the specified type. Parameters ---------- x : Union[float, int] The x-coordinate or width. y : Union[float, int] The y-coordinate or height. transformation : UnitTransformation, default="transform" The type of transformation to apply: - "transform" applies coordinate transformation from units to pixels. - "inverse_transform" applies coordinate transformation from pixels to units. - "transform_size" converts size from units to pixel values. - "inverse_transform_size" converts size from pixel values to units. Returns ------- Tuple[Union[int, float], Union[int, float]] The transformed coordinates or size based on the chosen transformation. """ if transformation == "transform": return self.transform(x, y) if transformation == "inverse_transform": return self.inverse_transform(x, y) if transformation == "transform_size": return self.transform_size(x, y) if transformation == "inverse_transform_size": return self.inverse_transform_size(x, y) raise ValueError(f"Unknown transformation type: {transformation}")
[docs] @abstractmethod def transform(self, x: float, y: float) -> Tuple[int, int]: """ Convert coordinates from units to pixel values. Parameters ---------- x : float The x-coordinate. y : float The y-coordinate. Returns ------- Tuple[int, int] The pixel coordinates. """
[docs] @abstractmethod def inverse_transform(self, x: int, y: int) -> Tuple[float, float]: """ Convert coordinates from pixel values to units. Parameters ---------- x : int The x-coordinate in pixels. y : int The y-coordinate in pixels. Returns ------- Tuple[float, float] The coordinates in the unit system. """
[docs] @abstractmethod def transform_size(self, width: float, height: float) -> Tuple[int, int]: """ Convert size from units to pixel values. Parameters ---------- width : float The width in the unit system. height : float The height in the unit system. Returns ------- Tuple[int, int] The width and height in pixel values. """
[docs] @abstractmethod def inverse_transform_size(self, width: int, height: int) -> Tuple[float, float]: """ Convert size from pixel values to units. Parameters ---------- width : int The width in pixels. height : int The height in pixels. Returns ------- Tuple[float, float] The width and height in the unit system. """
[docs] @register("px", UNIT_SYSTEMS) class PixelUnits(Unit): """ Pixel unit system. This class is a no-op, as Pyglet uses pixel units by default. """
[docs] def transform(self, x: float, y: float) -> Tuple[int, int]: return int(x), int(y)
[docs] def inverse_transform(self, x: int, y: int) -> Tuple[float, float]: return float(x), float(y)
[docs] def transform_size(self, width: float, height: float) -> Tuple[int, int]: return int(width), int(height)
[docs] def inverse_transform_size(self, width: int, height: int) -> Tuple[float, float]: return float(width), float(height)
[docs] @register("norm", UNIT_SYSTEMS) class NormalizedUnits(Unit): """ A unit system that normalizes coordinates and sizes with respect to the window dimensions. In this normalized system: - (1, 1) represents the top-right corner of the window. - (-1, -1) represents the bottom-left corner of the window. - (0, 0) represents the center of the window. - (-1, 1) represents the top-left corner of the window. - (1, -1) represents the bottom-right corner of the window. """
[docs] def transform(self, x: float, y: float) -> Tuple[int, int]: x_pixel = int((x + 1) * self.window.width / 2) x_pixel = min(max(x_pixel, 0), self.window.width - 1) y_pixel = int((1 + y) * self.window.height / 2) y_pixel = min(max(y_pixel, 0), self.window.height - 1) return x_pixel, y_pixel
[docs] def inverse_transform(self, x: int, y: int) -> Tuple[float, float]: x_unit = (x / self.window.width) * 2 - 1 y_unit = (y / self.window.height) * 2 - 1 return x_unit, y_unit
[docs] def transform_size(self, width: float, height: float) -> Tuple[int, int]: width_pixel = int(width * self.window.width / 2) height_pixel = int(height * self.window.height / 2) return width_pixel, height_pixel
[docs] def inverse_transform_size(self, width: int, height: int) -> Tuple[float, float]: width_unit = (width / self.window.width) * 2 height_unit = (height / self.window.height) * 2 return width_unit, height_unit
[docs] @register("%", UNIT_SYSTEMS) class PercentageUnit(Unit): """ Percentage unit system. - 100% width corresponds to the full width of the window. - 100% height corresponds to the full height of the window. """
[docs] def transform(self, x: float, y: float) -> Tuple[int, int]: x_pixel = int((x / 100) * self.window.width) y_pixel = int((y / 100) * self.window.height) return x_pixel, y_pixel
[docs] def inverse_transform(self, x: int, y: int) -> Tuple[float, float]: x_percentage = (x / self.window.width) * 100 y_percentage = (y / self.window.height) * 100 return x_percentage, y_percentage
[docs] def transform_size(self, width: float, height: float) -> Tuple[int, int]: width_pixel = int((width / 100) * self.window.width) height_pixel = int((height / 100) * self.window.height) return width_pixel, height_pixel
[docs] def inverse_transform_size(self, width: int, height: int) -> Tuple[float, float]: width_percentage = (width / self.window.width) * 100 height_percentage = (height / self.window.height) * 100 return width_percentage, height_percentage
[docs] @register("vw", UNIT_SYSTEMS) class VWUnit(Unit): """ VW (viewport width) unit system. - 1vw is 1% of the window's width. """
[docs] def transform(self, x: float, y: float) -> Tuple[int, int]: x_pixel = int((x / 100) * self.window.width) y_pixel = int((y / 100) * self.window.width) # vw is relative to window width return x_pixel, y_pixel
[docs] def inverse_transform(self, x: int, y: int) -> Tuple[float, float]: x_vw = (x / self.window.width) * 100 y_vw = (y / self.window.width) * 100 return x_vw, y_vw
[docs] def transform_size(self, width: float, height: float) -> Tuple[int, int]: width_pixel = int((width / 100) * self.window.width) height_pixel = int( (height / 100) * self.window.width ) # vw is relative to width return width_pixel, height_pixel
[docs] def inverse_transform_size(self, width: int, height: int) -> Tuple[float, float]: width_vw = (width / self.window.width) * 100 height_vw = (height / self.window.width) * 100 return width_vw, height_vw
[docs] @register("vh", UNIT_SYSTEMS) class VHUnit(Unit): """ VH (viewport height) unit system. - 1vh is 1% of the window's height. """
[docs] def transform(self, x: float, y: float) -> Tuple[int, int]: x_pixel = int((x / 100) * self.window.height) # vh is relative to window height y_pixel = int((y / 100) * self.window.height) return x_pixel, y_pixel
[docs] def inverse_transform(self, x: int, y: int) -> Tuple[float, float]: x_vh = (x / self.window.height) * 100 y_vh = (y / self.window.height) * 100 return x_vh, y_vh
[docs] def transform_size(self, width: float, height: float) -> Tuple[int, int]: width_pixel = int( (width / 100) * self.window.height ) # vh is relative to height height_pixel = int((height / 100) * self.window.height) return width_pixel, height_pixel
[docs] def inverse_transform_size(self, width: int, height: int) -> Tuple[float, float]: width_vh = (width / self.window.height) * 100 height_vh = (height / self.window.height) * 100 return width_vh, height_vh
[docs] @register("vd", UNIT_SYSTEMS) class VDUnit(Unit): """ Viewport Diagonal (vd) unit system. 1vd is 1% of the diagonal of the window. This unit is useful for ensuring elements are scaled proportionally based on the diagonal of the window. """ @property def diagonal(self) -> float: """ Calculate the diagonal of the window dynamically. """ return (self.window.width**2 + self.window.height**2) ** 0.5
[docs] def transform(self, x: float, y: float) -> Tuple[int, int]: x_pixel = int((x / 100) * self.diagonal) y_pixel = int((y / 100) * self.diagonal) return x_pixel, y_pixel
[docs] def inverse_transform(self, x: int, y: int) -> Tuple[float, float]: x_vd = (x / self.diagonal) * 100 y_vd = (y / self.diagonal) * 100 return x_vd, y_vd
[docs] def transform_size(self, width: float, height: float) -> Tuple[int, int]: width_pixel = int((width / 100) * self.diagonal) height_pixel = int((height / 100) * self.diagonal) return width_pixel, height_pixel
[docs] def inverse_transform_size(self, width: int, height: int) -> Tuple[float, float]: width_vd = (width / self.diagonal) * 100 height_vd = (height / self.diagonal) * 100 return width_vd, height_vd
[docs] @register("cm", UNIT_SYSTEMS) class CMUnit(Unit): """ Centimeter (cm) unit system. This unit system converts coordinates and sizes between centimeters and pixels using the screen's physical dimensions and resolution (DPI). - 1 cm corresponds to a specific number of pixels based on the screen's DPI. """ @property def pixels_per_cm(self) -> float: """ Calculate pixels per centimeter based on the current screen DPI. """ return self.window.dpi / 2.54 # 1 inch = 2.54 cm
[docs] def transform(self, x: float, y: float) -> Tuple[int, int]: x_pixel = int(x * self.pixels_per_cm) y_pixel = int(y * self.pixels_per_cm) return x_pixel, y_pixel
[docs] def inverse_transform(self, x: int, y: int) -> Tuple[float, float]: x_cm = x / self.pixels_per_cm y_cm = y / self.pixels_per_cm return x_cm, y_cm
[docs] def transform_size(self, width: float, height: float) -> Tuple[int, int]: width_pixel = int(width * self.pixels_per_cm) height_pixel = int(height * self.pixels_per_cm) return width_pixel, height_pixel
[docs] def inverse_transform_size(self, width: int, height: int) -> Tuple[float, float]: width_cm = width / self.pixels_per_cm height_cm = height / self.pixels_per_cm return width_cm, height_cm
[docs] @register("mm", UNIT_SYSTEMS) class MMUnit(Unit): """ Millimeter (mm) unit system. 1mm corresponds to a specific number of pixels based on the screen's DPI. This unit is useful for ensuring accurate physical dimensions in millimeters. """ @property def pixels_per_mm(self) -> float: """ Calculate pixels per millimeter based on the current screen DPI. """ return self.window.dpi / 25.4 # 1 inch = 25.4 mm
[docs] def transform(self, x: float, y: float) -> Tuple[int, int]: x_pixel = int(x * self.pixels_per_mm) y_pixel = int(y * self.pixels_per_mm) return x_pixel, y_pixel
[docs] def inverse_transform(self, x: int, y: int) -> Tuple[float, float]: x_mm = x / self.pixels_per_mm y_mm = y / self.pixels_per_mm return x_mm, y_mm
[docs] def transform_size(self, width: float, height: float) -> Tuple[int, int]: width_pixel = int(width * self.pixels_per_mm) height_pixel = int(height * self.pixels_per_mm) return width_pixel, height_pixel
[docs] def inverse_transform_size(self, width: int, height: int) -> Tuple[float, float]: width_mm = width / self.pixels_per_mm height_mm = height / self.pixels_per_mm return width_mm, height_mm
[docs] @register("in", UNIT_SYSTEMS) class INUnit(Unit): """ Inches (in) unit system. This unit system converts inches to pixels based on the screen's DPI. """ @property def dpi(self) -> float: """ Get the current screen DPI. """ return self.window.dpi
[docs] def transform(self, x: float, y: float) -> Tuple[int, int]: x_pixel = int(x * self.dpi) y_pixel = int(y * self.dpi) return x_pixel, y_pixel
[docs] def inverse_transform(self, x: int, y: int) -> Tuple[float, float]: x_in = x / self.dpi y_in = y / self.dpi return x_in, y_in
[docs] def transform_size(self, width: float, height: float) -> Tuple[int, int]: width_pixel = int(width * self.dpi) height_pixel = int(height * self.dpi) return width_pixel, height_pixel
[docs] def inverse_transform_size(self, width: int, height: int) -> Tuple[float, float]: width_in = width / self.dpi height_in = height / self.dpi return width_in, height_in
[docs] @register("pt", UNIT_SYSTEMS) class PTUnit(Unit): """ Points (pt) unit system. 1pt corresponds to a specific number of pixels based on the screen's DPI. This unit is useful for typographic elements or for aligning designs with text-based layouts. """ @property def pixels_per_pt(self) -> float: """ Calculate pixels per point based on the current screen DPI. """ return self.window.dpi / 72 # 1pt = 1/72 inch
[docs] def transform(self, x: float, y: float) -> Tuple[int, int]: x_pixel = int(x * self.pixels_per_pt) y_pixel = int(y * self.pixels_per_pt) return x_pixel, y_pixel
[docs] def inverse_transform(self, x: int, y: int) -> Tuple[float, float]: x_pt = x / self.pixels_per_pt y_pt = y / self.pixels_per_pt return x_pt, y_pt
[docs] def transform_size(self, width: float, height: float) -> Tuple[int, int]: width_pixel = int(width * self.pixels_per_pt) height_pixel = int(height * self.pixels_per_pt) return width_pixel, height_pixel
[docs] def inverse_transform_size(self, width: int, height: int) -> Tuple[float, float]: width_pt = width / self.pixels_per_pt height_pt = height / self.pixels_per_pt return width_pt, height_pt
[docs] @register("deg", UNIT_SYSTEMS) class DegUnit(Unit): """ Degrees of visual angle (deg) unit system. This unit system converts degrees of visual angle into pixels based on the distance between the viewer and the screen. It requires a known distance from the screen to accurately compute the size in pixels. """ @property def pixels_per_cm(self) -> float: """ Calculate pixels per centimeter based on the current screen DPI. """ return self.window.dpi / 2.54 # 1 inch = 2.54 cm @property def distance_cm(self) -> float: """ Get the distance from the viewer to the screen in centimeters. """ return self.window.distance
[docs] def transform(self, x: float, y: float) -> Tuple[int, int]: x_rad = (x / 360) * 2 * 3.14159 # Convert degrees to radians y_rad = (y / 360) * 2 * 3.14159 x_cm = 2 * self.distance_cm * (x_rad / 2) y_cm = 2 * self.distance_cm * (y_rad / 2) x_pixel = int(x_cm * self.pixels_per_cm) y_pixel = int(y_cm * self.pixels_per_cm) return x_pixel, y_pixel
[docs] def inverse_transform(self, x: int, y: int) -> Tuple[float, float]: x_cm = x / self.pixels_per_cm y_cm = y / self.pixels_per_cm x_rad = 2 * (x_cm / (2 * self.distance_cm)) y_rad = 2 * (y_cm / (2 * self.distance_cm)) x_deg = (x_rad / (2 * 3.14159)) * 360 y_deg = (y_rad / (2 * 3.14159)) * 360 return x_deg, y_deg
[docs] def transform_size(self, width: float, height: float) -> Tuple[int, int]: return self.transform(width, height)
[docs] def inverse_transform_size(self, width: int, height: int) -> Tuple[float, float]: return self.inverse_transform(width, height)
def _parse_size_value( value: Optional[Union[str, float, int]], default: str = "px" ) -> Tuple[float, str]: """ Parse a size value and return a tuple with the numeric value and the unit. Parameters ---------- value : Optional[Union[str, float, int]] The value to parse. Can be a number (int or float) or a string with units. default : str, default="px" The default unit to use if no unit is provided with the input. Returns ------- Tuple[float, str] A tuple containing the numeric value and the unit. Raises ------ ValueError If the input string format is incorrect or cannot be parsed. """ if value is None: return None, default # If the value is a number, return it with the default unit if isinstance(value, (int, float)): return float(value), default # If the value is a string, trim spaces and lowercase it value = value.strip().lower() # Regular expression to match {number}{optional spaces}{unit} match = re.match(r"(-?\d*\.?\d+)\s*([a-z%]*)", value) if not match: raise ValueError(f"Invalid format for size value: {value}") number = float(match.group(1)) # Convert the number part to float unit = match.group(2) or default # Use default unit if none provided return number, unit def parse_width( width: Optional[Union[str, float, int]], default: str = "px", window: "Window" = None, ) -> Optional[int]: """ Parse the width input and convert it into pixel values based on the specified units. Parameters ---------- width : Optional[Union[str, float, int]] The width to be parsed. Can be a number, a string with units, or None. default : str, default="px" The default unit system to use if no units are provided with the input. window : Optional[Window], default=None The window object used for unit conversion. If None, the global default window will be used. Returns ------- Optional[int] The parsed width in pixel values, or None if the input was None. Raises ------ ValueError If the input string format is incorrect or cannot be parsed. """ if width is None: return None # Parse the value to get the number and unit number, unit = _parse_size_value(width, default) # Get the appropriate unit system based on the unit name unit_system = Unit.from_name(unit, window) # Transform the width from the given unit to pixels return unit_system.transform_size(number, 0)[0] # Only the width is transformed def parse_height( height: Optional[Union[str, float, int]], default: str = "px", window: "Window" = None, ) -> Optional[int]: """ Parse the height input and convert it into pixel values based on the specified units. Parameters ---------- height : Optional[Union[str, float, int]] The height to be parsed. Can be a number, a string with units, or None. default : str, default="px" The default unit system to use if no units are provided with the input. window : Optional[Window], default=None The window object used for unit conversion. If None, the global default window will be used. Returns ------- Optional[int] The parsed height in pixel values, or None if the input was None. Raises ------ ValueError If the input string format is incorrect or cannot be parsed. """ if height is None: return None # Parse the value to get the number and unit number, unit = _parse_size_value(height, default) # Get the appropriate unit system based on the unit name unit_system = Unit.from_name(unit, window) # Transform the height from the given unit to pixels return unit_system.transform_size(0, number)[1]