Pupil Core device timing

PupilCore().light_stamper(...) marks the onset of a light stimulus by sending an annotation containing the timestamp of the first frame where the light becomes visible to the World camera. This timestamp is ultimately what gets used to extract pupil data and as a reference for calculating time-critical measures, such as constriction latency and time-to-peak constriction. The validity of any time-related measures therefore depends on Pupil Capture’s ability to synchronise the clocks of the cameras on a Pupil Core headset. How well does it handle this task? We were able to test camera clock synchronisation by putting the Pupil Core headset inside our integrating sphere and repeatedly flashing a bright orange light containing enough near infrared to afford detection by the Eye cameras as well as the World camera. Prior to each flash, concurrent .light_stamper(...)’s were instantiated, giving us the timestamp of the frame where the luminance change was detected independently for each camera. This protocol allowed us to get some insight into how well the data streams are synchronised.

We ran the testing protocol on Mac and Windows (Pupil Capture v3.2-20) for n = 100 near-IR light flashes. For each run of the protocol, Eye camera resolution was kept at (192, 192) with Absolute Exposure Time of 25, and the World camera had (640, 480) and 60. Auto Exposure Mode was set to ‘manual mode’ for all cameras, and Auto Exposure Priority was disabled for the World camera. Whilst all of these camera settings may have the potential to impact the precision of the .light_stamper(...), Frame rate is the most obvious. So we performed separate trials on Mac and Windows with a Frame rate of 60 and 120.

[1]:
import os
import os.path as op
import glob

import numpy as np
import pandas as pd
import seaborn as sns

Calculate differences from annotation timestamps

[2]:
datadir = '../data/pupil_core_camera_sync_tests'
recordings = glob.glob(datadir + '/**/**/**/annotations.csv')

data = pd.DataFrame()
for rec in recordings:
    # Load annotations and get camera timestamps for detected light flash
    df = pd.read_csv(rec)
    eye_0 = df.loc[df.label=='light_on_eye_0', 'timestamp'].to_numpy()
    eye_1 = df.loc[df.label=='light_on_eye_1', 'timestamp'].to_numpy()
    world = df.loc[df.label=='light_on_world', 'timestamp'].to_numpy()

    # Calculate timestamp differences and make DataFrame
    diffs = (pd.DataFrame(data=[(eye_0-world)*1000,
                                (eye_1-world)*1000,
                                (eye_0-eye_1)*1000],
                          index=['eye0 - world',
                                 'eye1 - world',
                                 'eye0 - eye1'])
               .T.melt(var_name='Comparison',
                       value_name='Timestamp difference (ms)'))

    # Add categories for operating system and frames per second
    diffs['OS'] = 'macOS' if 'mac' in rec else 'Windows'
    diffs['FPS'] = '120' if '120_fps' in rec else '60'
    diffs.to_csv(op.join(op.dirname(rec), 'annotation_timestamp_diffs.csv'))

    # Append to master frame
    data = data.append(diffs)
data
[2]:
Comparison Timestamp difference (ms) OS FPS
0 eye0 - world 58.807 Windows 120
1 eye0 - world 55.010 Windows 120
2 eye0 - world 58.925 Windows 120
3 eye0 - world 57.622 Windows 120
4 eye0 - world 58.371 Windows 120
... ... ... ... ...
295 eye0 - eye1 -3.959 macOS 60
296 eye0 - eye1 -3.968 macOS 60
297 eye0 - eye1 12.266 macOS 60
298 eye0 - eye1 -3.984 macOS 60
299 eye0 - eye1 -3.991 macOS 60

1200 rows × 4 columns

Plot the timestamp differences

[4]:
g = sns.catplot(data=data,
                x='Comparison',
                y='Timestamp difference (ms)',
                row='OS',
                col='FPS',
                kind='box',
                order=['eye0 - world', 'eye1 - world', 'eye0 - eye1'],
                flierprops={'markersize':3})
_images/06b_pupil_core_timing_analysis_5_0.png

Interpretation

These data show the time difference between when the same light flash was detected by independant .light_stamper(...)’s running concurrently for both Eye cameras and the World camera. On Mac and Windows, the Eye cameras appear to be well-synchronised with a margin of error that is to be expected given the frame rate. On Windows, the light flashes were consistently stamped on the World camera around 60 ms before they were stamped on the Eye cameras for both 60 and 120 FPS. The same pattern was observed, though to a lesser degree, with Mac. The timestamps were best synchronised on Mac OS with cameras running at 120 FPS, where the World camera lead by 15 ms on average.

Implications

It is difficult to interpret these data without a detailed understanding of the inner workings of the Pupil software. But in practical terms, as far as the PLR is concerned, our data suggest that any time-critical measures using a World camera .light_stamper(...) timestamp as a reference point will be consistently overestimated by 15 to 60 ms, depending on the operating system and camera settings being used. But, given that these timestamp discrepenacies are highly repeatable and potentially correctable, they do not preclude researchers from obtaining time-critical measures of the PLR.

It is worth noting that the ‘ground truth’ for measures of constriction latency and pupil size is inaccessible, and that values depend strongly on hardware, settings, and measurement principles. Our data show at least that with Pupil Core we can achieve a level of precision that rivals that of automated pupillometers.