{ "cells": [ { "cell_type": "markdown", "id": "0", "metadata": {}, "source": [ "# Plotting Gaze Data" ] }, { "cell_type": "markdown", "id": "1", "metadata": {}, "source": [ "The pymovements library provides a collection of built-in plotting functions\n", "to visualize gaze data in both temporal and spatial dimensions.\n", "\n", "These functions make it easy to explore and present your data, \n", "from individual trial visualizations to aggregated participant-level analyses.\n", "\n", "In this tutorial, you’ll learn how to:\n", "\n", "- load and prepare a sample dataset for plotting\n", "- compute the necessary events and properties\n", "- plot gaze traces over time (traceplot)\n", "- visualize fixations on a stimulus (scanpathplot)\n", "- generate heatmaps showing gaze density (heatmap)\n", "- plot the saccadic main sequence (main-sequence)\n", "- style the plot\n", "\n", "All examples use the small {py:class}`~pymovements.datasets.ToyDataset` that comes with pymovements." ] }, { "cell_type": "markdown", "id": "2", "metadata": {}, "source": [ "## Loading and preprocessing data" ] }, { "cell_type": "code", "execution_count": null, "id": "3", "metadata": {}, "outputs": [], "source": [ "import polars as pl\n", "\n", "import pymovements as pm\n", "\n", "dataset = pm.Dataset('ToyDataset', path='data/ToyDataset')\n", "dataset.download()\n", "dataset.load()\n", "\n", "# Convert the raw x and y coordinates in pixels to degrees of visual angle\n", "dataset.pix2deg()\n", "\n", "# Compute gaze velocities in dva/s from dva coordinates.\n", "dataset.pos2vel('smooth')" ] }, { "cell_type": "markdown", "id": "4", "metadata": {}, "source": [ "## Plot Raw Samples" ] }, { "cell_type": "markdown", "id": "5", "metadata": {}, "source": [ "To visualize the raw gaze data, we first select a recording and extract the horizontal and vertical coordinates." ] }, { "cell_type": "code", "execution_count": null, "id": "6", "metadata": {}, "outputs": [], "source": [ "# we will work with gaze data from the first recording\n", "gaze = dataset.gaze[0]\n", "\n", "# extract horizontal and vertical coordinates from the position column\n", "df = gaze.samples\n", "\n", "df = df.with_columns([\n", " pl.col(\"position\").list.get(0).alias(\"pos_x\"),\n", " pl.col(\"position\").list.get(1).alias(\"pos_y\"),\n", "])\n", "\n", "# Assign back\n", "gaze.samples = df" ] }, { "cell_type": "markdown", "id": "7", "metadata": {}, "source": [ "### Tsplot\n", "\n", "The {py:func}`~pymovements.plotting.tsplot` function produces a time series plot of gaze samples from a {py:class}`~pymovements.Gaze` object. A time series plot shows how each recorded signal changes over time, with one line per selected channel (e.g., horizontal and vertical gaze position).\n", "\n", "In this example, we plot the `pos_x` and `pos_y` channels to examine the raw gaze signal before applying any event detection or preprocessing. We observe rapid jumps in the horizontal gaze position (`pos_x`), and slower, more gradual changes in the vertical gaze position (`pos_y`).\n" ] }, { "cell_type": "code", "execution_count": null, "id": "8", "metadata": {}, "outputs": [], "source": [ "pm.plotting.tsplot(\n", " gaze,\n", " channels=['pos_x', 'pos_y'],\n", " # Set separate y-axis for each channel.\n", " share_y=False,\n", " line_color=\"darkblue\")" ] }, { "cell_type": "markdown", "id": "9", "metadata": {}, "source": [ "### Traceplot" ] }, { "cell_type": "markdown", "id": "10", "metadata": {}, "source": [ "The {py:func}`~pymovements.plotting.traceplot` function visualizes the raw gaze samples as a continuous trajectory across the stimulus. In a traceplot, each gaze sample is connected in temporal order, showing how the point of regard moves over time.\n", "\n", "Traceplots are useful for:\n", "- Verifying that gaze data have been parsed and aligned correctly.\n", "- Exploring viewing behavior across conditions or participants.\n", "- Identifying artifacts or data quality issues.\n", "\n", "A basic traceplot can be created with only a {py:class}`~pymovements.Gaze` object:" ] }, { "cell_type": "code", "execution_count": null, "id": "11", "metadata": {}, "outputs": [], "source": [ "pm.plotting.traceplot(gaze)" ] }, { "cell_type": "markdown", "id": "12", "metadata": {}, "source": [ "## Detecting and Visualizing Events" ] }, { "cell_type": "markdown", "id": "13", "metadata": {}, "source": [ "Eye-tracking data are typically segmented into events, i.e. fixations and saccades. Fixations represent moments when the eyes remain relatively still, allowing visual information to be processed, while saccades are the rapid movements between fixations that reposition the gaze. Detecting these events and computing their properties, such as fixation duration, saccade amplitude, and peak velocity, provides the foundation for analyzing visual behavior and understanding how participants explore a stimulus." ] }, { "cell_type": "markdown", "id": "14", "metadata": {}, "source": [ "### Fixations\n", "\n", "We can detect fixations by applying the I-VT or the I-DT method. \n", "\n", "The **I-VT (Velocity-Threshold Identification)** method distinguishes fixation and saccade points based on their point-to-point velocities. Each point is classified as a fixation if its velocity is below the specified threshold. Consecutive fixation points are then merged into a single fixation. A threshold of 20 degrees/second is commonly used as a default maximum value. Read more about the IVT method in the documentation: {py:func}`pymovements.events.detection.ivt`. \n", "\n", "The **I-DT (Dispersion-Threshold Identification)** method finds fixations by grouping consecutive points within a maximum separation (dispersion) threshold and a minimum duration threshold. The algorithm slides a moving window across the data: if the dispersion within the window is below the threshold, the window represents a fixation and is gradually expanded until the dispersion exceeds the threshold.\n", "Read more about our implementation of the IDT method: {py:func}`pymovements.events.detection.idt`." ] }, { "cell_type": "markdown", "id": "15", "metadata": {}, "source": [ "We will use the I-DT algorithm with different dispersion threshold values to create two different sets of fixation events.\n", "\n", "**Key Parameters:**\n", "- `dispersion_threshold`: Maximum dispersion allowed for fixation points. Default: 1.0 degrees\n", "- `name`: Custom name for the detected events\n", "\n", "The `mininum_duration` default is 100 ms." ] }, { "cell_type": "code", "execution_count": null, "id": "16", "metadata": {}, "outputs": [], "source": [ "# Detect fixations with a stricter threshold (1.0 degrees)\n", "dataset.detect_events('idt', dispersion_threshold=1.0, name='fixation_1.0_idt')\n", "\n", "# Detect fixations with a standard threshold (2.7 degrees)\n", "dataset.detect_events('idt', dispersion_threshold=2.7, name='fixation_2.7_idt')" ] }, { "cell_type": "markdown", "id": "17", "metadata": {}, "source": [ "### Calculating Fixation Properties\n", "\n", "The property `location` will be used for visualization purposes. It is added as a separate column named `location` in the events DataFrame, containing the centroid coordinates of each fixation. \n", "\n", "**Key Parameter:**\n", "- `position_column`: Specifies which coordinate system to use for the property. By default, fixation centroids are computed in degrees of visual angle. To obtain fixation centroids in pixel coordinates, this parameter must be explicitly set to `pixel`. " ] }, { "cell_type": "code", "execution_count": null, "id": "18", "metadata": {}, "outputs": [], "source": [ "# Compute fixation locations using pixel coordinates\n", "dataset.compute_event_properties((\"location\", {'position_column': 'pixel'}))" ] }, { "cell_type": "markdown", "id": "19", "metadata": {}, "source": [ "### Creating the Scanpath Plot" ] }, { "cell_type": "markdown", "id": "20", "metadata": {}, "source": [ "The {py:func}`~pymovements.plotting.scanpathplot` function visualizes the sequence of fixations as circles placed at their spatial locations, with circle size indicating fixation duration. Each fixation has an arrow pointing to the next fixation in viewing order." ] }, { "cell_type": "code", "execution_count": null, "id": "21", "metadata": {}, "outputs": [], "source": [ "# show all unique event names in the gaze events frame\n", "gaze.events.frame.select('name').unique().to_series().to_list()" ] }, { "cell_type": "code", "execution_count": null, "id": "22", "metadata": {}, "outputs": [], "source": [ "pm.plotting.scanpathplot(gaze, event_name='fixation_1.0_idt')" ] }, { "cell_type": "code", "execution_count": null, "id": "23", "metadata": {}, "outputs": [], "source": [ "pm.plotting.scanpathplot(gaze, event_name='fixation_2.7_idt')" ] }, { "cell_type": "markdown", "id": "96f3c21a-8a0e-4e41-bb37-f24b536f25de", "metadata": {}, "source": [ "By default, arrows are curved to prevent overlap with the stimulus (e.g., text). The curvature of the arrows is controlled by the `arrow_rad` parameter. Setting `arrow_rad` to zero disables the curvature and results in straight arrows, as shown below." ] }, { "cell_type": "code", "execution_count": null, "id": "65552ab1-4ddb-45bd-b431-203296b11aa1", "metadata": {}, "outputs": [], "source": [ "pm.plotting.scanpathplot(gaze, event_name='fixation_2.7_idt', arrow_rad=0.0)" ] }, { "cell_type": "markdown", "id": "24", "metadata": {}, "source": [ "We can create an enhanced visualization by overlaying the scanpath plot with the traceplot. This shows both the fixations, their duration, and the raw gaze trajectory." ] }, { "cell_type": "code", "execution_count": null, "id": "25", "metadata": {}, "outputs": [], "source": [ "pm.plotting.scanpathplot(gaze, event_name='fixation_2.7_idt', add_traceplot=True, add_arrows=False)" ] }, { "cell_type": "markdown", "id": "26", "metadata": {}, "source": [ "### Heatmap Plotting" ] }, { "cell_type": "markdown", "id": "27", "metadata": {}, "source": [ "The heatmap visualizes the spatial distribution of gaze samples across the experiment screen. Each cell's color value reflects the cumulative time (in seconds) that the gaze samples were recorded at that position.\n", "\n", "We can use the {py:func}`~pymovements.plotting.heatmap` from the `pymovements` library with the default values for `gridsize` (10x10), interpolation, and the colorbar." ] }, { "cell_type": "code", "execution_count": null, "id": "28", "metadata": {}, "outputs": [], "source": [ "pm.plotting.heatmap(gaze)" ] }, { "cell_type": "markdown", "id": "29", "metadata": {}, "source": [ "Furthermore, we can customize various aspects of the heatmap plot, such as the grid size, color map, and the labels." ] }, { "cell_type": "code", "execution_count": null, "id": "30", "metadata": {}, "outputs": [], "source": [ "fig, ax = pm.plotting.heatmap(\n", " gaze=gaze,\n", " position_column='pixel',\n", " origin='upper',\n", " show_cbar=True,\n", " cbar_label='Time [s]',\n", " title='Gaze Heatmap with Interpolation On',\n", " xlabel='X [pix]',\n", " ylabel='Y [pix]',\n", " gridsize=[10, 10],\n", ")" ] }, { "cell_type": "markdown", "id": "31", "metadata": {}, "source": [ "To better understand the effect of the `gridsize` parameter on the heatmap, we can turn off the interpolation. By doing this, we can clearly visualize the individual bins used to calculate the heatmap. With interpolation turned off, the heatmap will display the raw bin values rather than a smoothed representation." ] }, { "cell_type": "code", "execution_count": null, "id": "32", "metadata": {}, "outputs": [], "source": [ "fig, ax = pm.plotting.heatmap(\n", " gaze,\n", " position_column='pixel',\n", " origin='upper',\n", " show_cbar=True,\n", " cbar_label='Time [s]',\n", " title='Gaze Heatmap with Interpolation Off',\n", " xlabel='X [pix]',\n", " ylabel='Y [pix]',\n", " gridsize=[10, 10],\n", " interpolation='none'\n", ")" ] }, { "cell_type": "markdown", "id": "33", "metadata": {}, "source": [ "Increasing the `gridsize` parameter results in a finer grid and more detailed heatmap representation. With a higher grid size, we divide the plot into smaller bins, which can capture more nuances in the data distribution" ] }, { "cell_type": "code", "execution_count": null, "id": "34", "metadata": {}, "outputs": [], "source": [ "fig, ax = pm.plotting.heatmap(\n", " dataset.gaze[5],\n", " position_column='pixel',\n", " origin='upper',\n", " show_cbar=True,\n", " cbar_label='Time [s]',\n", " title='Gaze Heatmap with Higher Grid Size',\n", " xlabel='X [pix]',\n", " ylabel='Y [pix]',\n", " gridsize=[25, 25]\n", ")" ] }, { "cell_type": "markdown", "id": "35", "metadata": {}, "source": [ "### Detect Saccades and Compute Amplitude and Peak Velocity\n", "\n", "Saccades are rapid eye movements that shift the point of fixation from one location to another. We detect saccades (or micro-saccades) from the velocity sequence of gaze data using the {py:func}`~pymovements.events.detection.microsaccades` algorithm. This algorithm implements a noise-adaptive velocity threshold, meaning that the detection threshold automatically scales with the noise level of the velocity signal.\n", "\n", "**Key Parameters:**\n", "- `threshold_factor`: Multiplier used to determine the velocity threshold relative to the noise level of the signal. The default value is 6. A higher factor makes the algorithm more conservative (detects fewer saccades), while a lower factor makes it more sensitive.\n", "- `minimum_duration`: Defines how long a velocity peak must persist to be classified as a saccade. The duration is expressed in the same units as timesteps. If no timesteps are provided, the value refers to the number of samples (default = 6), which corresponds to about 12 ms at a 500 Hz sampling rate. Shorter events are ignored as noise. " ] }, { "cell_type": "code", "execution_count": null, "id": "36", "metadata": {}, "outputs": [], "source": [ "# detect saccades using the microsaccades algorithm\n", "dataset.detect_events('microsaccades', minimum_duration=6, threshold_factor=6)" ] }, { "cell_type": "code", "execution_count": null, "id": "37", "metadata": {}, "outputs": [], "source": [ "# compute amplitude and peak velocity of the detected saccades\n", "dataset.compute_event_properties(['amplitude', 'peak_velocity'])\n", "\n", "# the DataFrame with detected events should now contain the following columns:\n", "# name, onset, offset, duration, amplitude, peak_velocity, location\n", "dataset.events[0]" ] }, { "cell_type": "markdown", "id": "38", "metadata": {}, "source": [ "### Plotting the Saccadic Main Sequence" ] }, { "cell_type": "markdown", "id": "39", "metadata": {}, "source": [ "The saccadic main sequence describes the characteristic relationship between a saccade's amplitude and its peak velocity: larger saccades tend to be faster, following a nonlinear, saturating curve. It is commonly used to validate saccade detection and assess data quality, since deviations from the expected pattern can indicate recording errors or atypical oculomotor behavior. \n", "\n", "Optionally, a linear fit can be added to the plot (via `fit=True`), together with an evaluation metric (`fit_measure='r2' or 's'`) to quantify how well the detected saccades follow the expected main-sequence relationship.\n", "\n", "We employ the {py:func}`~pymovements.plotting.main_sequence_plot` function to createt this visualization." ] }, { "cell_type": "code", "execution_count": null, "id": "40", "metadata": {}, "outputs": [], "source": [ "# show the first three event dataframes.\n", "# note that you can adjust the styling of the plot, e.g. setting a low\n", "# alpha value allows you to change transparency to see overlapping data points\n", "for event_df in dataset.events[:3]:\n", " pm.plotting.main_sequence_plot(\n", " event_df,\n", " event_name='saccade',\n", " fit=True,\n", " title='Main sequence plot',\n", " marker='x',\n", " marker_size=30,\n", " marker_color='green',\n", " marker_alpha=0.5,\n", " )" ] } ], "metadata": {}, "nbformat": 4, "nbformat_minor": 5 }