Skip to content

eyepy.quant

Quantification module for ophthalmic image analysis.

This module provides tools for quantifying features in ophthalmic images, including area measurements and spatial extent calculations relative to anatomical landmarks.

AnatomicalOrigin(y, x, laterality, mode) dataclass

Reference origin based on anatomical landmarks.

Defines a coordinate system origin that can be based on the optic disc, fovea, a hybrid approach, or a custom position.

Coordinate System Convention: This class uses (row, col) image coordinates for input and output: - row: vertical axis, increases downward (corresponds to y) - col: horizontal axis, increases rightward (corresponds to x)

The internal storage uses (y, x) where y=row and x=col. All methods that accept or return coordinates use (row, col) format unless explicitly documented otherwise.

Attributes:

Name Type Description
y float

Vertical (y) coordinate of the origin

x float

Horizontal (x) coordinate of the origin

laterality str

Eye laterality ('OD' or 'OS')

mode OriginMode

Origin mode used to determine the position

from_custom(origin, laterality) classmethod

Create origin at custom position.

Parameters:

Name Type Description Default
origin tuple[float, float]

(y, x) coordinates of custom origin

required
laterality str

Eye laterality ('OD' or 'OS')

required

Returns:

Type Description
AnatomicalOrigin

AnatomicalOrigin at custom position

Source code in src/eyepy/quant/spatial.py
@classmethod
def from_custom(
    cls,
    origin: tuple[float, float],
    laterality: str,
) -> 'AnatomicalOrigin':
    """Create origin at custom position.

    Args:
        origin: (y, x) coordinates of custom origin
        laterality: Eye laterality ('OD' or 'OS')

    Returns:
        AnatomicalOrigin at custom position
    """
    if laterality not in ['OD', 'OS']:
        raise ValueError(f'Laterality must be OD or OS, got {laterality}')

    return cls(
        y=origin[0],
        x=origin[1],
        laterality=laterality,
        mode=OriginMode.CUSTOM,
    )

from_fovea(fovea_center, laterality) classmethod

Create origin at fovea center.

Parameters:

Name Type Description Default
fovea_center tuple[float, float]

(y, x) coordinates of fovea center

required
laterality str

Eye laterality ('OD' or 'OS')

required

Returns:

Type Description
AnatomicalOrigin

AnatomicalOrigin at fovea center

Source code in src/eyepy/quant/spatial.py
@classmethod
def from_fovea(
    cls,
    fovea_center: tuple[float, float],
    laterality: str,
) -> 'AnatomicalOrigin':
    """Create origin at fovea center.

    Args:
        fovea_center: (y, x) coordinates of fovea center
        laterality: Eye laterality ('OD' or 'OS')

    Returns:
        AnatomicalOrigin at fovea center
    """
    if laterality not in ['OD', 'OS']:
        raise ValueError(f'Laterality must be OD or OS, got {laterality}')

    return cls(
        y=fovea_center[0],
        x=fovea_center[1],
        laterality=laterality,
        mode=OriginMode.FOVEA,
    )

from_hybrid(optic_disc_center, fovea_center, laterality) classmethod

Create hybrid origin from optic disc and fovea positions.

Uses the horizontal (x) position from the optic disc center and the vertical (y) position from the fovea center.

Parameters:

Name Type Description Default
optic_disc_center tuple[float, float]

(y, x) coordinates of optic disc center

required
fovea_center tuple[float, float]

(y, x) coordinates of fovea center

required
laterality str

Eye laterality ('OD' or 'OS')

required

Returns:

Type Description
AnatomicalOrigin

AnatomicalOrigin with y from fovea, x from optic disc

Source code in src/eyepy/quant/spatial.py
@classmethod
def from_hybrid(
    cls,
    optic_disc_center: tuple[float, float],
    fovea_center: tuple[float, float],
    laterality: str,
) -> 'AnatomicalOrigin':
    """Create hybrid origin from optic disc and fovea positions.

    Uses the horizontal (x) position from the optic disc center and
    the vertical (y) position from the fovea center.

    Args:
        optic_disc_center: (y, x) coordinates of optic disc center
        fovea_center: (y, x) coordinates of fovea center
        laterality: Eye laterality ('OD' or 'OS')

    Returns:
        AnatomicalOrigin with y from fovea, x from optic disc
    """
    if laterality not in ['OD', 'OS']:
        raise ValueError(f'Laterality must be OD or OS, got {laterality}')

    # Origin: horizontal position from OD, vertical position from fovea
    origin_y = fovea_center[0]
    origin_x = optic_disc_center[1]

    return cls(
        y=origin_y,
        x=origin_x,
        laterality=laterality,
        mode=OriginMode.HYBRID,
    )

from_optic_disc(optic_disc_center, laterality) classmethod

Create origin at optic disc center.

Parameters:

Name Type Description Default
optic_disc_center tuple[float, float]

(y, x) coordinates of optic disc center

required
laterality str

Eye laterality ('OD' or 'OS')

required

Returns:

Type Description
AnatomicalOrigin

AnatomicalOrigin at optic disc center

Source code in src/eyepy/quant/spatial.py
@classmethod
def from_optic_disc(
    cls,
    optic_disc_center: tuple[float, float],
    laterality: str,
) -> 'AnatomicalOrigin':
    """Create origin at optic disc center.

    Args:
        optic_disc_center: (y, x) coordinates of optic disc center
        laterality: Eye laterality ('OD' or 'OS')

    Returns:
        AnatomicalOrigin at optic disc center
    """
    if laterality not in ['OD', 'OS']:
        raise ValueError(f'Laterality must be OD or OS, got {laterality}')

    return cls(
        y=optic_disc_center[0],
        x=optic_disc_center[1],
        laterality=laterality,
        mode=OriginMode.OPTIC_DISC,
    )

to_cartesian(y, x)

Convert image coordinates (row, col) to Cartesian coordinates relative to origin.

Image coordinates use (row, col) convention where: - row increases downward - col increases to the right

Cartesian output coordinates are anatomically oriented: - x: horizontal (positive = temporal, negative = nasal) - y: vertical (positive = inferior, negative = superior)

Parameters:

Name Type Description Default
y float

Image row coordinate (increases downward)

required
x float

Image column coordinate (increases to the right)

required

Returns:

Type Description
float

(x_cart, y_cart) Cartesian coordinates relative to origin where:

float
  • x_cart: horizontal distance (positive = temporal, negative = nasal)
tuple[float, float]
  • y_cart: vertical distance (positive = inferior/downward, negative = superior/upward)
Source code in src/eyepy/quant/spatial.py
def to_cartesian(self, y: float, x: float) -> tuple[float, float]:
    """Convert image coordinates (row, col) to Cartesian coordinates relative
    to origin.

    Image coordinates use (row, col) convention where:
    - row increases downward
    - col increases to the right

    Cartesian output coordinates are anatomically oriented:
    - x: horizontal (positive = temporal, negative = nasal)
    - y: vertical (positive = inferior, negative = superior)

    Args:
        y: Image row coordinate (increases downward)
        x: Image column coordinate (increases to the right)

    Returns:
        (x_cart, y_cart) Cartesian coordinates relative to origin where:
        - x_cart: horizontal distance (positive = temporal, negative = nasal)
        - y_cart: vertical distance (positive = inferior/downward, negative = superior/upward)
    """
    # Compute displacement in image coordinates
    dx_image = x - self.x  # Column displacement
    dy_image = y - self.y  # Row displacement (positive = downward)

    # In Cartesian coordinates:
    # - y_cart = dy_image (positive downward = inferior, negative upward = superior)
    # - x_cart needs laterality adjustment for temporal/nasal

    y_cart = dy_image  # Positive = inferior/downward, negative = superior/upward

    # Adjust for laterality: temporal direction
    # OD (right eye): nasal is to the right (positive dx), temporal is to the left (negative dx)
    #                 So temporal (positive x_cart) = negative dx_image
    # OS (left eye): nasal is to the left (negative dx), temporal is to the right (positive dx)
    #                So temporal (positive x_cart) = positive dx_image
    if self.laterality == 'OD':
        x_cart = -dx_image
    else:  # OS
        x_cart = dx_image

    return (x_cart, y_cart)

to_polar(y, x)

Convert image coordinates (row, col) to polar coordinates.

Parameters:

Name Type Description Default
y float

Image row coordinate (increases downward)

required
x float

Image column coordinate (increases to the right)

required

Returns:

Type Description
float

(distance, angle) where:

float
  • distance: Euclidean distance from origin
tuple[float, float]
  • angle: Angle in radians (0 = temporal, π/2 = inferior, π = nasal, 3π/2 = superior)
Source code in src/eyepy/quant/spatial.py
def to_polar(self, y: float, x: float) -> tuple[float, float]:
    """Convert image coordinates (row, col) to polar coordinates.

    Args:
        y: Image row coordinate (increases downward)
        x: Image column coordinate (increases to the right)

    Returns:
        (distance, angle) where:
        - distance: Euclidean distance from origin
        - angle: Angle in radians (0 = temporal, π/2 = inferior,
                 π = nasal, 3π/2 = superior)
    """
    x_cart, y_cart = self.to_cartesian(y, x)
    distance = np.sqrt(x_cart**2 + y_cart**2)
    angle = np.arctan2(y_cart, x_cart)  # Range: [-π, π]

    # Convert to [0, 2π] with 0 = temporal
    if angle < 0:
        angle += 2 * np.pi

    return (distance, angle)

DirectionalExtent(temporal, nasal, superior, inferior, superior_temporal, inferior_temporal, superior_nasal, inferior_nasal) dataclass

Extent of a region in specific directions from origin.

Contains ExtentMetrics for each of the 8 anatomical directions (4 cardinal + 4 ordinal) from an anatomical origin. Each direction includes mean, max, median, and standard deviation of boundary distances, along with a flag indicating if that specific direction touches the image border.

Attributes:

Name Type Description
temporal ExtentMetrics

Extent metrics in temporal direction

nasal ExtentMetrics

Extent metrics in nasal direction

superior ExtentMetrics

Extent metrics in superior direction

inferior ExtentMetrics

Extent metrics in inferior direction

superior_temporal ExtentMetrics

Extent metrics in superior-temporal direction

inferior_temporal ExtentMetrics

Extent metrics in inferior-temporal direction

superior_nasal ExtentMetrics

Extent metrics in superior-nasal direction

inferior_nasal ExtentMetrics

Extent metrics in inferior-nasal direction

to_dict()

Convert to dictionary.

Returns:

Type Description
dict

Dictionary with nested structure: direction -> metric -> value

Source code in src/eyepy/quant/spatial.py
def to_dict(self) -> dict:
    """Convert to dictionary.

    Returns:
        Dictionary with nested structure: direction -> metric -> value
    """
    return {
        'temporal': self.temporal.to_dict(),
        'nasal': self.nasal.to_dict(),
        'superior': self.superior.to_dict(),
        'inferior': self.inferior.to_dict(),
        'superior_temporal': self.superior_temporal.to_dict(),
        'inferior_temporal': self.inferior_temporal.to_dict(),
        'superior_nasal': self.superior_nasal.to_dict(),
        'inferior_nasal': self.inferior_nasal.to_dict(),
    }

ExtentMetrics(midpoint, mean, max, median, std, touches_border=False) dataclass

Metrics for extent in a single direction.

Contains statistical measures of distances from the origin to boundary points within a single angular sector.

Attributes:

Name Type Description
midpoint float

Distance to boundary at exact midpoint angle of this direction

mean float

Mean distance to boundary points in this direction

max float

Maximum distance to boundary in this direction

median float

Median distance to boundary points in this direction

std float

Standard deviation of distances in this direction

touches_border bool

Whether the region extends to the image border in this specific direction, indicating measurements may be truncated

to_dict()

Convert to dictionary.

Returns:

Type Description
dict

Dictionary mapping metric names to values

Source code in src/eyepy/quant/spatial.py
def to_dict(self) -> dict:
    """Convert to dictionary.

    Returns:
        Dictionary mapping metric names to values
    """
    return {
        'midpoint': self.midpoint,
        'mean': self.mean,
        'max': self.max,
        'median': self.median,
        'std': self.std,
        'touches_border': self.touches_border,
    }

OriginMode

Bases: Enum

Mode for determining the anatomical origin.

OPTIC_DISC: Use optic disc center as origin FOVEA: Use fovea center as origin HYBRID: Use optic disc x-coordinate and fovea y-coordinate CUSTOM: Use a custom user-specified origin point

PolarReference(origin)

Polar coordinate reference system for spatial analysis.

Divides the image into 8 angular sectors (4 cardinal + 4 ordinal directions) relative to an anatomical origin for computing directional statistics.

Attributes:

Name Type Description
origin

Anatomical origin point

Initialize polar reference system.

Parameters:

Name Type Description Default
origin AnatomicalOrigin

Anatomical origin defining the coordinate system

required
Source code in src/eyepy/quant/spatial.py
def __init__(
    self,
    origin: AnatomicalOrigin,
):
    """Initialize polar reference system.

    Args:
        origin: Anatomical origin defining the coordinate system
    """
    self.origin = origin

compute_directional_extent(mask, scale_x=1.0, scale_y=1.0)

Compute extent in all 8 directions with complete metrics.

For each of the 8 anatomical directions (4 cardinal + 4 ordinal), computes: - Midpoint distance: distance to boundary at exact direction angle - Mean, max, median, std: statistics of all boundary points in that sector - Border flag: whether that specific direction touches the image border

Parameters:

Name Type Description Default
mask NDArray[bool_]

Binary mask of the region

required
scale_x float

Micrometers per pixel in x-direction

1.0
scale_y float

Micrometers per pixel in y-direction

1.0

Returns:

Type Description
DirectionalExtent

DirectionalExtent with ExtentMetrics for all 8 directions

Source code in src/eyepy/quant/spatial.py
def compute_directional_extent(
    self,
    mask: npt.NDArray[np.bool_],
    scale_x: float = 1.0,
    scale_y: float = 1.0,
) -> DirectionalExtent:
    """Compute extent in all 8 directions with complete metrics.

    For each of the 8 anatomical directions (4 cardinal + 4 ordinal),
    computes:
    - Midpoint distance: distance to boundary at exact direction angle
    - Mean, max, median, std: statistics of all boundary points in that sector
    - Border flag: whether that specific direction touches the image border

    Args:
        mask: Binary mask of the region
        scale_x: Micrometers per pixel in x-direction
        scale_y: Micrometers per pixel in y-direction

    Returns:
        DirectionalExtent with ExtentMetrics for all 8 directions
    """
    # Define the 8 directions with their midpoint angles and sector ranges
    # Each sector spans π/4 radians (45 degrees) centered on its midpoint
    directions_config = {
        'temporal': {
            'midpoint_angle': 0.0,
            'sector_range': (-np.pi / 8, np.pi / 8),
        },
        'inferior_temporal': {
            'midpoint_angle': np.pi / 4,
            'sector_range': (np.pi / 8, 3 * np.pi / 8),
        },
        'inferior': {
            'midpoint_angle': np.pi / 2,
            'sector_range': (3 * np.pi / 8, 5 * np.pi / 8),
        },
        'inferior_nasal': {
            'midpoint_angle': 3 * np.pi / 4,
            'sector_range': (5 * np.pi / 8, 7 * np.pi / 8),
        },
        'nasal': {
            'midpoint_angle': np.pi,
            'sector_range': ((7 * np.pi / 8, np.pi), (-np.pi, -7 * np.pi / 8)),
        },
        'superior_nasal': {
            'midpoint_angle': -3 * np.pi / 4,
            'sector_range': (-7 * np.pi / 8, -5 * np.pi / 8),
        },
        'superior': {
            'midpoint_angle': -np.pi / 2,
            'sector_range': (-5 * np.pi / 8, -3 * np.pi / 8),
        },
        'superior_temporal': {
            'midpoint_angle': -np.pi / 4,
            'sector_range': (-3 * np.pi / 8, -np.pi / 8),
        },
    }

    if not np.any(mask):
        # Empty mask - return zeros for all directions
        zero_metrics = ExtentMetrics(
            midpoint=0.0, mean=0.0, max=0.0, median=0.0, std=0.0, touches_border=False
        )
        return DirectionalExtent(
            temporal=zero_metrics,
            nasal=zero_metrics,
            superior=zero_metrics,
            inferior=zero_metrics,
            superior_temporal=zero_metrics,
            inferior_temporal=zero_metrics,
            superior_nasal=zero_metrics,
            inferior_nasal=zero_metrics,
        )

    # Extract boundary pixels, properly handling border cases
    y_coords, x_coords, _ = _extract_boundary_with_border(mask)

    if len(y_coords) == 0:
        # Single pixel mask - use the pixel itself
        y_coords, x_coords = np.where(mask)

    # Compute metrics for all 8 directions
    mask_shape = (mask.shape[0], mask.shape[1])
    metrics = {}
    for direction_name, config in directions_config.items():
        metrics[direction_name] = self._compute_single_direction_metrics(
            y_coords=y_coords,
            x_coords=x_coords,
            scale_x=scale_x,
            scale_y=scale_y,
            midpoint_angle=config['midpoint_angle'],
            sector_range=config['sector_range'],
            mask_shape=mask_shape,
        )

    return DirectionalExtent(
        temporal=metrics['temporal'],
        nasal=metrics['nasal'],
        superior=metrics['superior'],
        inferior=metrics['inferior'],
        superior_temporal=metrics['superior_temporal'],
        inferior_temporal=metrics['inferior_temporal'],
        superior_nasal=metrics['superior_nasal'],
        inferior_nasal=metrics['inferior_nasal'],
    )

compute_area(mask, scale_x=1.0, scale_y=1.0)

Compute area of a binary mask in physical units.

Parameters:

Name Type Description Default
mask NDArray[bool_]

Binary mask (True = region of interest)

required
scale_x float

Micrometers per pixel in x-direction (default: 1.0)

1.0
scale_y float

Micrometers per pixel in y-direction (default: 1.0)

1.0

Returns:

Type Description
float

Area in square micrometers (or square pixels if scales are 1.0)

Source code in src/eyepy/quant/metrics.py
def compute_area(
    mask: npt.NDArray[np.bool_],
    scale_x: float = 1.0,
    scale_y: float = 1.0,
) -> float:
    """Compute area of a binary mask in physical units.

    Args:
        mask: Binary mask (True = region of interest)
        scale_x: Micrometers per pixel in x-direction (default: 1.0)
        scale_y: Micrometers per pixel in y-direction (default: 1.0)

    Returns:
        Area in square micrometers (or square pixels if scales are 1.0)
    """
    pixel_area = scale_x * scale_y
    n_pixels = np.sum(mask)
    return float(n_pixels * pixel_area)