"""Abstract base class for calibration display backends.
This module defines the interface that all calibration backends must implement.
Backends provide visualization during tracker calibration and validation.
"""
import logging
from abc import ABC, abstractmethod
import numpy as np
import pylink
from PIL import Image
from .. import audio
logger = logging.getLogger(__name__)
[docs]
class CalibrationDisplay(pylink.EyeLinkCustomDisplay, ABC):
"""Abstract base class for EyeLink calibration displays.
All backend implementations must inherit from this class and implement
the required abstract methods. This class defines the interface for
drawing calibration targets, handling input, and displaying the eye camera view.
"""
[docs]
def __init__(self, settings: object, tracker: object, mode: str = "normal") -> None:
"""Initialize calibration display.
Args:
settings: Settings object with configuration
tracker: EyeLink tracker instance
mode: Calibration mode - "normal", "calibration-only", or "validation-only"
"""
pylink.EyeLinkCustomDisplay.__init__(self)
self.settings = settings
self.sres = settings.screen_res
self.mode = mode
self._last_target_xy: tuple[int, int] | None = None
self.set_tracker(tracker)
[docs]
def set_tracker(self, tracker: object) -> None:
"""Configure tracker for calibration.
Args:
tracker: EyeLinkDevice instance
"""
self.tracker = tracker
self.tracker_version = tracker.get_tracker_version()
if self.tracker_version >= 3:
# enable_search_limits: Enables use/display of global search limits (ON or OFF)
# track_search_limits: Enables tracking of pupil to global search limits (ON or OFF)
self.tracker.send_command(f"enable_search_limits={self.settings.enable_search_limits}")
self.tracker.send_command(f"track_search_limits={self.settings.track_search_limits}")
# autothreshold_click: Auto-threshold on mouse click on setup mode image
# autothreshold_repeat: Allows repeat of auto-threshold if pupil not centered on first
self.tracker.send_command(f"autothreshold_click={self.settings.autothreshold_click}")
self.tracker.send_command(f"autothreshold_repeat={self.settings.autothreshold_repeat}")
# enable_camera_position_detect: Allows camera position detect on click/auto-threshold in setup mode (TRUE or FALSE)
self.tracker.send_command(f"enable_camera_position_detect={self.settings.enable_camera_position_detect}")
[docs]
@abstractmethod
def setup_cal_display(self) -> None:
"""Initialize calibration display.
Called when entering calibration mode. Should clear the display
and show any initial instructions or setup.
"""
[docs]
@abstractmethod
def exit_cal_display(self) -> None:
"""Clean up calibration display.
Called when exiting calibration mode.
"""
[docs]
@abstractmethod
def clear_cal_display(self) -> None:
"""Clear the calibration display.
Typically called between calibration points.
"""
[docs]
@abstractmethod
def draw_cal_target(self, x: float, y: float) -> None:
"""Draw calibration target at position (x, y).
Args:
x: X coordinate in EyeLink screen coordinates (top-left origin)
y: Y coordinate in EyeLink screen coordinates (top-left origin)
Note:
Coordinates are in EyeLink space (top-left origin, positive Y down).
Backends must convert to their native coordinate system.
Backend implementations should call ``_log_target_drawn(x, y)``
after the display has been updated, so that an EDF message is
emitted when ``settings.log_calibration_target_messages`` is True.
"""
def _log_target_drawn(self, x: float, y: float) -> None:
"""Emit a ``TARGET x=<x> y=<y>`` message to the EyeLink.
Gated by ``settings.log_calibration_target_messages`` (default False).
Backends should call this from ``draw_cal_target`` after the display
flip so the message timestamp lines up with the visible target. The
check is cheap when the flag is off.
Also stashes (x, y) on the instance so a paired ``TARGET_ERASED``
message can carry the same coordinates.
"""
self._last_target_xy = (int(x), int(y))
if not self.settings.log_calibration_target_messages:
return
self.tracker.send_message(f"TARGET x={int(x)} y={int(y)}")
def _log_target_erased(self) -> None:
"""Emit a 'TARGET_ERASED x=<x> y=<y>' message to the EyeLink.
Coordinates are taken from the most recent ``_log_target_drawn``
call, so a downstream parser can pair the draw/erase events. Backends
should call this from ``erase_cal_target`` after the display flip.
Gated by ``settings.log_calibration_target_messages`` (default False).
"""
if not self.settings.log_calibration_target_messages:
return
if self._last_target_xy is None:
self.tracker.send_message("TARGET_ERASED")
else:
x, y = self._last_target_xy
self.tracker.send_message(f"TARGET_ERASED x={x} y={y}")
[docs]
@abstractmethod
def erase_cal_target(self) -> None:
"""Remove calibration target from display.
Called after participant has fixated the target.
"""
[docs]
@abstractmethod
def setup_image_display(self, width: int, height: int) -> None:
"""Initialize camera image display.
Called before displaying the eye camera feed.
Args:
width: Image width in pixels
height: Image height in pixels
"""
[docs]
def image_title(self, text: str) -> None:
"""Display title/info text on camera view.
Args:
text: Text to display (typically pupil/CR information)
"""
self.image_title_text = text
[docs]
def getColorFromIndex(self, colorindex: int) -> tuple[int, int, int]: # noqa: N802, PLR6301
"""Map pylink color constants to RGB tuples.
Args:
colorindex: Pylink color constant
Returns:
tuple: (R, G, B) values in range 0-255
"""
color_map = {
pylink.CR_HAIR_COLOR: (255, 255, 255), # Corneal reflection crosshair
pylink.PUPIL_HAIR_COLOR: (255, 255, 255), # Pupil crosshair
pylink.PUPIL_BOX_COLOR: (0, 255, 0), # Pupil box (green)
pylink.SEARCH_LIMIT_BOX_COLOR: (255, 0, 0), # Search limit box (red)
pylink.MOUSE_CURSOR_COLOR: (255, 0, 0), # Mouse cursor (red)
}
return color_map.get(colorindex, (128, 128, 128))
[docs]
def set_image_palette(self, r: object, g: object, b: object) -> None:
"""Set color palette for camera image.
Args:
r: Red channel values (list/array)
g: Green channel values (list/array)
b: Blue channel values (list/array)
"""
sz = len(r)
self.rgb_palette = np.zeros((sz, 3), dtype=np.uint8)
for i in range(sz):
self.rgb_palette[i] = (int(r[i]), int(g[i]), int(b[i]))
[docs]
def draw_image_line_base(self, width: int, line: int, totlines: int, buff: object) -> tuple:
"""The EyeLink sends the camera image line-by-line. This method receives each line and accumulates them.
When line == totlines, the complete image is ready and overlays (crosshairs, etc.) are drawn.
Args:
width: Width of the image line
line: Current line number (1-indexed)
totlines: Total number of lines in the image
buff: Buffer containing pixel data for this line
Returns:
tuple: (image, imgstim_size) if all lines received, else (None, None)
"""
if not self._accumulate_image_line(width, line, totlines, buff):
return None, None
image, imgstim_size = self._get_processed_pil_image()
self.__img__ = image
self.draw_cross_hair()
self.__img__ = None
return image, imgstim_size
def _accumulate_image_line(self, width: int, line: int, totlines: int, buff: object) -> bool:
"""Accumulate camera image line by line.
Args:
width: Width of the image line
line: Current line number (1-indexed)
totlines: Total number of lines in the image
buff: Buffer containing pixel data for this line
Returns:
bool: True if all lines received, False otherwise
"""
if self.rgb_index_array is None or self.rgb_index_array.shape != (totlines, width):
self.size = (width, totlines)
self.rgb_index_array = np.zeros((totlines, width), dtype=np.uint8)
self.imgstim_size = None
for i in range(width):
self.rgb_index_array[line - 1, i] = buff[i]
return line == totlines
def _build_rgb_image_from_palette(self) -> Image.Image:
"""Convert indexed image array to RGB using palette.
Returns:
PIL.Image.Image: RGB image
"""
if self.rgb_palette is not None:
rgb_image = self.rgb_palette[self.rgb_index_array]
return Image.fromarray(rgb_image.astype(np.uint8), "RGB")
image = Image.fromarray(self.rgb_index_array)
return image.convert("RGB")
def _calculate_image_display_size(self) -> tuple[int, int]:
"""Calculate display size to fit half screen width.
Returns:
tuple[int, int]: (width, height) for display
"""
if self.imgstim_size is None:
maxsz = self.sres[0] / 2
mx = 1.0
while (mx + 1) * self.size[0] <= maxsz:
mx += 1.0
self.imgstim_size = (int(self.size[0] * mx), int(self.size[1] * mx))
return self.imgstim_size
def _get_processed_pil_image(self) -> tuple[Image.Image, tuple[int, int]]:
"""Builds, scales, and returns the PIL image along with its display size.
This encapsulates the common PIL image processing steps used by backends.
Returns:
tuple[PIL.Image.Image, tuple[int, int]]: A tuple containing the processed
PIL Image and its calculated display size (width, height).
"""
image = self._build_rgb_image_from_palette()
imgstim_size = self._calculate_image_display_size()
# Use NEAREST resampling for pixel-art like scaling
image = image.resize(imgstim_size, Image.Resampling.NEAREST)
return image, imgstim_size
[docs]
@abstractmethod
def exit_image_display(self) -> None:
"""Clean up camera image display."""
[docs]
@abstractmethod
def draw_line(self, x1: float, y1: float, x2: float, y2: float, colorindex: int) -> None:
"""Draw line on camera view.
Called by EyeLink to draw crosshairs on pupil and corneal reflection.
Backends should draw using their native drawing API for best performance.
Args:
x1: X coordinate of start point
y1: Y coordinate of start point
x2: X coordinate of end point
y2: Y coordinate of end point
colorindex: Pylink color constant
"""
[docs]
@abstractmethod
def draw_lozenge(self, x: float, y: float, width: float, height: float, colorindex: int) -> None:
"""Draw rectangle on camera view.
Called by EyeLink to draw pupil box and search limits.
Backends should draw using their native drawing API for best performance.
Args:
x: X coordinate of top-left corner
y: Y coordinate of top-left corner
width: Width of lozenge
height: Height of lozenge
colorindex: Pylink color constant
"""
[docs]
@abstractmethod
def get_mouse_state(self) -> tuple | None:
"""Get mouse position and button state.
Returns:
tuple: ((x, y), button_state) or None if not implemented
- (x, y): Mouse position in EyeLink coordinates
- button_state: 1 if button pressed, 0 otherwise
"""
return None
[docs]
@staticmethod
def play_beep(beepid: int) -> None:
"""Play audio beep for calibration feedback.
Args:
beepid: Beep type constant from pylink:
- pylink.CAL_TARG_BEEP / pylink.DC_TARG_BEEP: Target appears
- pylink.CAL_GOOD_BEEP / pylink.DC_GOOD_BEEP: Calibration point accepted
- pylink.CAL_ERR_BEEP / pylink.DC_ERR_BEEP: Calibration point failed
"""
if beepid in {pylink.DC_TARG_BEEP, pylink.CAL_TARG_BEEP}:
audio.play_target_beep()
elif beepid in {pylink.CAL_ERR_BEEP, pylink.DC_ERR_BEEP}:
audio.play_error_beep()
else: # CAL_GOOD_BEEP or DC_GOOD_BEEP
audio.play_done_beep()
[docs]
@staticmethod
def alert_printf(msg: str) -> None:
"""Display alert message.
Args:
msg: Alert message to display
"""
logger.warning("EyeLink Alert: %s", msg)
[docs]
def record_abort_hide(self) -> None:
"""Handle recording abort."""