Source code for pyelink.calibration.psychopy_backend

"""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 get_input_key(self) -> list: """Get keyboard input and convert to pylink key codes. Filters 'c' and 'v' keys based on calibration mode: - "normal": both 'c' and 'v' enabled - "calibration-only": only 'c' enabled, 'v' disabled - "validation-only": only 'v' enabled, 'c' disabled Returns: list: List of pylink.KeyInput objects """ ky = [] v = event.getKeys(modifiers=True) # Map PsychoPy key names to pylink key constants key_map = { "escape": pylink.ESC_KEY, "return": pylink.ENTER_KEY, " ": ord(" "), "space": ord(" "), # PsychoPy might return "space" instead of " " "c": ord("c"), "v": ord("v"), "a": ord("a"), "pageup": pylink.PAGE_UP, "pagedown": pylink.PAGE_DOWN, "-": ord("-"), "=": ord("="), "up": pylink.CURS_UP, "down": pylink.CURS_DOWN, "left": pylink.CURS_LEFT, "right": pylink.CURS_RIGHT, } for key_info in v: # key_info is (key_name, modifiers_dict) or just key_name if isinstance(key_info, tuple): char, mods = key_info else: char = key_info mods = {} # Handle Ctrl+C for graceful shutdown if char == "c" and mods.get("ctrl", False): self.tracker.display.shutdown_handler(None, None) return ky # Skip other keys with Ctrl modifier if mods.get("ctrl", False): continue # Filter 'c' and 'v' keys based on mode if char == "c" and self.mode in {"validation-only", "camera-setup"}: continue # Skip 'c' in validation-only and camera-setup modes if char == "v" and self.mode in {"calibration-only", "camera-setup"}: continue # Skip 'v' in calibration-only and camera-setup modes # Lookup key in the general key map pylink_key = key_map.get(char) if pylink_key is not None: ky.append(pylink.KeyInput(pylink_key, 0)) return ky
[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()