Source code for physical_apertures.polygon

"""Polygon Aperture Module

This module contains the PolygonAperture class, which represents a polygonal
aperture that clips rays based on a set of vertices. It also contains the
FileAperture class, which reads an aperture definition from a file and creates
a polygon-based aperture.

Kramer Harrison, 2025
"""

from __future__ import annotations

import numpy as np

import optiland.backend as be
from optiland.physical_apertures.base import BaseAperture


[docs] class PolygonAperture(BaseAperture): """Represents a polygonal aperture that clips rays based on their position. Attributes: x (list or be.ndarray): x-coordinates of the polygon's vertices. y (list or be.ndarray): y-coordinates of the polygon's vertices. vertices (be.ndarray): Array-like of shape (n, 2) defining the polygon vertices. Note: The implementation of the point-in-polygon algorithm used in this class has slightly different behavior for each backend. FOr the NumPy backend, points on the edge of the polygon are considered to be inside the polygon, while for the PyTorch backend, they are considered to be outside. """ def __init__(self, x, y): super().__init__() self.x = be.array(x) self.y = be.array(y) self.vertices = be.column_stack((self.x, self.y)) @property def extent(self): """Returns the extent of the aperture. Returns: tuple: The extent of the aperture in the x and y directions. """ return (self.x.min(), self.x.max(), self.y.min(), self.y.max())
[docs] def contains(self, x, y): """Checks if the given point is inside the aperture. Args: x (be.ndarray): The x-coordinate of the point. y (be.ndarray): The y-coordinate of the point. Returns: be.ndarray: Boolean array indicating if the point is inside the aperture """ x = be.array(x) y = be.array(y) pts = be.column_stack((x.ravel(), y.ravel())) mask_flat = be.path_contains_points(self.vertices, pts) return mask_flat.reshape(x.shape)
[docs] def scale(self, scale_factor): """Scales the aperture by the given factor. Args: scale_factor (float): The factor by which to scale the aperture. """ self.vertices = self.vertices * scale_factor self.x = self.vertices[:, 0] self.y = self.vertices[:, 1]
[docs] def to_dict(self): """Convert the aperture to a dictionary. Returns: dict: The dictionary representation of the aperture. """ aperture_dict = super().to_dict() aperture_dict["x"] = be.to_numpy(self.x) aperture_dict["y"] = be.to_numpy(self.y) return aperture_dict
[docs] @classmethod def from_dict(cls, data): """Create an aperture from a dictionary representation. Args: data (dict): The dictionary representation of the aperture. Returns: PolygonAperture: The aperture object. """ return cls(data["x"], data["y"])
[docs] class FileAperture(PolygonAperture): """Reads an aperture definition from a file and creates a polygon-based aperture. The file should contain two columns representing the x and y coordinates, respectively. It supports various file formats (e.g. CSV, TXT) by allowing you to specify a delimiter and the number of header lines to skip. Comments can be added to the file by starting a line with '//'. Args: filepath (str): Path to the aperture file. delimiter (str or None): Delimiter used to separate values in the file. If None, the class will attempt to auto-detect the delimiter. skip_header (int): Number of lines to skip at the start of the file. Raises: ValueError: If the file cannot be read or does not contain exactly two columns. """ def __init__(self, filepath, delimiter=None, skip_header=0): self.filepath = filepath self.delimiter = delimiter self.skip_header = skip_header x, y = self._load_vertices(filepath, delimiter, skip_header) super().__init__(x, y) def _load_vertices(self, filepath, delimiter, skip_header): """Load x and y vertices from the specified file. Args: filepath (str): Path to the file. delimiter (str or None): Delimiter used to separate values. skip_header (int): Number of header lines to skip. Returns: tuple: Two numpy arrays (x, y) containing the coordinate data. Raises: ValueError: If the file cannot be parsed or does not contain at exactly two columns. """ encodings = [ "utf-8", "utf-16", "utf-16le", "utf-16be", "utf-32", "utf-32le", "utf-32be", "latin1", "ascii", ] data = None for encoding in encodings: try: with open(filepath, encoding=encoding) as f: # delimiter defaults to space if not specified delim = delimiter if delimiter is not None else " " data = np.genfromtxt( f, delimiter=delim, comments="//", skip_header=skip_header, ) if data is not None: break except UnicodeDecodeError: continue if data is None or data.ndim != 2 or data.shape[1] != 2: raise ValueError(f'Error reading aperture file "{filepath}"') x = data[:, 0] y = data[:, 1] return x, y
[docs] def to_dict(self): """Convert the aperture to a dictionary. Returns: dict: The dictionary representation of the aperture. """ aperture_dict = super().to_dict() aperture_dict["filepath"] = self.filepath aperture_dict["delimiter"] = self.delimiter aperture_dict["skip_header"] = self.skip_header return aperture_dict
[docs] @classmethod def from_dict(cls, data): """Create an aperture from a dictionary representation. Args: data (dict): The dictionary representation of the aperture. Returns: FileAperture: The aperture object. """ return cls(data["filepath"], data["delimiter"], data["skip_header"])