"""PsychoPy backend for EyeLink calibration display.
This module provides PsychoPy-based visualization for EyeLink calibration and validation.
"""
import logging
import tempfile
from pathlib import Path
import numpy as np
import pylink
from psychopy import event, visual
from .base import CalibrationDisplay
from .targets import generate_target
logger = logging.getLogger(__name__)
[docs]
class PsychopyCalibrationDisplay(CalibrationDisplay):
"""PsychoPy implementation of EyeLink calibration display."""
[docs]
def __init__(self, settings: object, tracker: object, mode: str = "normal") -> None:
"""Initialize PsychoPy calibration display.
Args:
settings: Settings object with configuration
tracker: EyeLink tracker instance (with display.window attribute)
mode: Calibration mode - "normal", "calibration-only", or "validation-only"
"""
super().__init__(settings, tracker, mode)
self.settings = settings
# Get PsychoPy window from tracker
self.window = tracker.display.window
self.window.flip(clearBuffer=True)
self.mouse = None
self.width, self.height = self.window.size
# Store original background color and set calibration colors
# Convert RGB (0-255) to PsychoPy range (-1 to 1)
self.original_color = self.window.color
rgb = settings.cal_background_color
self.backcolor = [(c / 255.0) * 2 - 1 for c in rgb]
text_rgb = settings.calibration_text_color
self.txtcol = [(c / 255.0) * 2 - 1 for c in text_rgb]
# Set window to calibration background color
self.window.color = self.backcolor
# Generate target image (pass PIL image directly - PsychoPy handles it properly)
pil_image = generate_target(settings)
self.target_image = visual.ImageStim(
self.window,
image=pil_image, # PIL image, not numpy array
units="pix",
)
# Image drawing variables (used for camera display)
self.rgb_index_array = None
self.rgb_pallete = None
self.image_title_text = ""
self.imgstim_size = None
self.eye_image = None
self.lineob = None
self.loz = None
# Overlay drawing variables
self.overlay_lines = []
self.overlay_rects = []
[docs]
def setup_cal_display(self) -> None:
"""Initialize calibration display with instructions."""
# Use custom callback if provided
if self.settings.calibration_instruction_page_callback:
self.settings.calibration_instruction_page_callback(self.window)
return
# Draw instruction text centered on screen (if not empty)
if self.settings.calibration_instruction_text:
instr_stim = visual.TextStim(
self.window,
self.settings.calibration_instruction_text,
pos=(0, 0),
color=tuple(self.txtcol),
units="pix",
height=self.settings.calibration_text_font_size,
font=self.settings.calibration_text_font_name,
)
instr_stim.draw()
self.window.flip()
[docs]
def exit_cal_display(self) -> None:
"""Clean up calibration display and restore original window color."""
# Restore original window background color
self.window.color = self.original_color
self.window.flip(clearBuffer=True)
[docs]
def close_window(self) -> None:
"""Close the psychopy window."""
self.window.close()
[docs]
def clear_cal_display(self) -> None:
"""Clear calibration display."""
self.setup_cal_display()
[docs]
def erase_cal_target(self) -> None:
"""Remove calibration target from display."""
self.window.flip()
self._log_target_erased()
[docs]
def draw_cal_target(self, x: float, y: float) -> None:
"""Draw calibration target at position (x, y).
Args:
x: X coordinate in EyeLink coordinates (top-left origin)
y: Y coordinate in EyeLink coordinates (top-left origin)
"""
# Convert to PsychoPy coordinates (center origin, positive Y up)
psy_x = x - self.sres[0] / 2
psy_y = -(y - (self.sres[1] / 2))
self.target_image.pos = (psy_x, psy_y)
self.target_image.draw()
self.window.flip()
self._log_target_drawn(x, y)
[docs]
def setup_image_display(self, width: int, height: int) -> None:
"""Initialize camera image display.
Args:
width: Image width in pixels
height: Image height in pixels
"""
self.size = (width, height)
self.clear_cal_display()
self.last_mouse_state = -1
# Create array to hold image data - always recreate to match current size
self.rgb_index_array = np.zeros((self.size[1], self.size[0]), dtype=np.uint8)
self.imgstim_size = None # Reset to recalculate display size
[docs]
def draw_image_line(self, width: int, line: int, totlines: int, buff: object) -> None:
"""Draw 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
"""
# Accumulate image lines
if not self._accumulate_image_line(width, line, totlines, buff):
return # Not all lines received yet
# Build and scale RGB image
image, imgstim_size = self._get_processed_pil_image()
# Save image as a temporary file
tfile = Path(tempfile.gettempdir()) / "_eleye.png"
image.save(str(tfile), "PNG")
# Need this for target distance to show up
self.__img__ = image
self.draw_cross_hair()
self.__img__ = None
# Create or update eye image
if self.eye_image is None:
self.eye_image = visual.ImageStim(self.window, tfile, size=imgstim_size, units="pix")
else:
self.eye_image.setImage(tfile)
# Redraw the Camera Setup Mode graphics
self.eye_image.draw()
# Draw all overlays
for overlay_line in self.overlay_lines:
overlay_line.draw()
for overlay_rect in self.overlay_rects:
overlay_rect.draw()
# Draw title text
if self.image_title_text:
self._draw_title()
# Display
self.window.flip()
# Clear overlays for next frame
self.overlay_lines = []
self.overlay_rects = []
[docs]
def set_image_palette(self, r: object, g: object, b: object) -> None:
"""Set color palette for camera image.
Args:
r: Red channel values
g: Green channel values
b: Blue channel values
"""
# Call parent implementation
super().set_image_palette(r, g, b)
# PsychoPy-specific: clear display when palette is set
self.clear_cal_display()
[docs]
def exit_image_display(self) -> None:
"""Clean up camera image display."""
self.clear_cal_display()
[docs]
def getColorFromIndex(self, colorindex: int) -> tuple: # noqa: N802, PLR6301
"""Map pylink color constants to PsychoPy color values.
Args:
colorindex: Pylink color constant
Returns:
tuple: (R, G, B) in PsychoPy color space (-1 to 1)
"""
if colorindex in {pylink.CR_HAIR_COLOR, pylink.PUPIL_HAIR_COLOR}:
return (1, 1, 1)
if colorindex == pylink.PUPIL_BOX_COLOR:
return (-1, 1, -1)
if colorindex in {pylink.SEARCH_LIMIT_BOX_COLOR, pylink.MOUSE_CURSOR_COLOR}:
return (1, -1, -1)
return (-1, -1, -1)
[docs]
def get_mouse_state(self) -> tuple | None:
"""Get mouse position and button state."""
if self.mouse is None:
self.mouse = event.Mouse(win=self.window)
pos = self.mouse.getPos()
buttons = self.mouse.getPressed()
x = int(pos[0] + (self.sres[0] / 2))
y = int(pos[1] + (self.sres[1] / 2))
button_state = 1 if any(buttons) else 0
return ((x, y), button_state)
def _eyelink_to_psychopy(self, x: float, y: float) -> tuple[float, float]:
"""Convert EyeLink camera image coordinates to PsychoPy screen coordinates.
Args:
x: X coordinate in camera image space (top-left origin, 0 to size[0])
y: Y coordinate in camera image space (top-left origin, 0 to size[1])
Returns:
tuple: (x, y) in PsychoPy space (center origin, positive Y up)
"""
if self.size is None or self.imgstim_size is None:
return (0.0, 0.0)
# Scale from camera image space to display size
scale_x = self.imgstim_size[0] / self.size[0]
scale_y = self.imgstim_size[1] / self.size[1]
x_scaled = x * scale_x
y_scaled = y * scale_y
# Convert from top-left origin to center origin
# Camera image is centered at (0, 0) in PsychoPy
x_psycho = x_scaled - (self.imgstim_size[0] / 2)
y_psycho = -y_scaled + (self.imgstim_size[1] / 2) # Flip Y axis
return x_psycho, y_psycho
[docs]
def draw_line(self, x1: float, y1: float, x2: float, y2: float, colorindex: int) -> None:
"""Draw line on camera image.
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
"""
x1_psycho, y1_psycho = self._eyelink_to_psychopy(x1, y1)
x2_psycho, y2_psycho = self._eyelink_to_psychopy(x2, y2)
color = self.getColorFromIndex(colorindex)
line = visual.Line(
self.window,
start=(x1_psycho, y1_psycho),
end=(x2_psycho, y2_psycho),
lineColor=color,
units="pix",
)
self.overlay_lines.append(line)
[docs]
def draw_lozenge(self, x: float, y: float, width: float, height: float, colorindex: int) -> None:
"""Draw rectangle on camera image.
Args:
x: X coordinate of top-left corner
y: Y coordinate of top-left corner
width: Width of rectangle
height: Height of rectangle
colorindex: Pylink color constant
"""
if self.size is None or self.imgstim_size is None:
return
# Scale width and height from camera image space to display size
scale_x = self.imgstim_size[0] / self.size[0]
scale_y = self.imgstim_size[1] / self.size[1]
width_scaled = width * scale_x
height_scaled = height * scale_y
# Convert top-left corner to center position for PsychoPy
center_x = x + width / 2
center_y = y + height / 2
center_x_psycho, center_y_psycho = self._eyelink_to_psychopy(center_x, center_y)
color = self.getColorFromIndex(colorindex)
rect = visual.Rect(
self.window,
width=width_scaled,
height=height_scaled,
pos=(center_x_psycho, center_y_psycho),
lineColor=color,
fillColor=None,
units="pix",
)
self.overlay_rects.append(rect)
def _draw_title(self) -> None:
"""Draw title text on camera image."""
if not self.image_title_text:
return
title_stim = visual.TextStim(
self.window,
text=self.image_title_text,
pos=(0, self.height // 2 - 20),
height=18,
color=self.txtcol,
units="pix",
font="Arial",
)
title_stim.draw()
[docs]
def dummynote(self) -> None:
"""Display message for dummy mode (no hardware connection)."""
# Draw Text
visual.TextStim(
self.window,
text="Dummy Connection with EyeLink - Press SPACE to continue",
color=self.txtcol,
font="Arial",
).draw()
self.window.flip()
# Wait for spacebar press (use display backend to handle Ctrl+C)
waiting = True
while waiting:
events = self.tracker.display.get_events()
for evt in events:
if evt.get("type") == "keydown" and evt.get("key") == "space":
waiting = False
self.window.flip()