├── .Rbuildignore ├── .gitignore ├── DESCRIPTION ├── NAMESPACE ├── NEWS ├── R ├── eyelink_parser.R ├── eyelinker.R └── utils.R ├── README.md ├── inst └── extdata │ ├── bino1000.asc.gz │ ├── bino250.asc.gz │ ├── bino500.asc.gz │ ├── binoRemote250.asc.gz │ ├── binoRemote500.asc.gz │ ├── mono1000.asc.gz │ ├── mono2000.asc.gz │ ├── mono250.asc.gz │ ├── mono500.asc.gz │ ├── monoRemote250.asc.gz │ └── monoRemote500.asc.gz ├── man ├── eyelinker.Rd ├── grapes-In-grapes.Rd ├── read.asc.Rd └── whichInterval.Rd ├── tests ├── testthat.R └── testthat │ └── test_examples.R └── vignettes └── basics.Rmd /.Rbuildignore: -------------------------------------------------------------------------------- 1 | ## Ignore travis config file 2 | ^\.travis\.yml$ 3 | ^appveyor\.yml$ 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files 2 | *.slo 3 | *.lo 4 | *.o 5 | *.obj 6 | 7 | # Precompiled Headers 8 | *.gch 9 | *.pch 10 | # Compiled Dynamic libraries 11 | *.so 12 | *.dylib 13 | *.dll 14 | # Fortran module files 15 | *.mod 16 | # Compiled Static libraries 17 | *.lai 18 | *.la 19 | *.a 20 | *.lib 21 | # Executables 22 | *.exe 23 | *.out 24 | *.app 25 | #Emacs temp files 26 | *~ 27 | [#]*[#] 28 | .\#* 29 | #R temp files 30 | *.Rhistory 31 | *.Rcheck 32 | #Files generated by configure 33 | config.log 34 | config.status 35 | Makevars 36 | inst/doc 37 | -------------------------------------------------------------------------------- /DESCRIPTION: -------------------------------------------------------------------------------- 1 | Package: eyelinker 2 | Type: Package 3 | Title: Load Raw Data from Eyelink Eye Trackers 4 | Version: 0.1 5 | Date: 2016-04-20 6 | Author: Simon Barthelme 7 | Maintainer: Simon Barthelme 8 | Description: Eyelink eye trackers output a horrible mess, typically under 9 | the form of a '.asc' file. The file in question is an assorted collection of 10 | messages, events and raw data. This R package will attempt to make sense of it. 11 | Depends: 12 | R (>= 3.1.1) 13 | Imports: 14 | plyr, 15 | stringi, 16 | stringr, 17 | readr, 18 | magrittr, 19 | intervals, 20 | purrr 21 | License: GPL-3 22 | RoxygenNote: 5.0.1 23 | Suggests: testthat, 24 | knitr, 25 | rmarkdown, 26 | dplyr, 27 | ggplot2, 28 | tidyr 29 | VignetteBuilder: knitr 30 | -------------------------------------------------------------------------------- /NAMESPACE: -------------------------------------------------------------------------------- 1 | # Generated by roxygen2: do not edit by hand 2 | 3 | export("%In%") 4 | export(read.asc) 5 | export(whichInterval) 6 | importFrom(intervals,Intervals) 7 | importFrom(intervals,distance_to_nearest) 8 | importFrom(intervals,which_nearest) 9 | importFrom(magrittr,"%>%") 10 | importFrom(plyr,dlply) 11 | importFrom(plyr,ldply) 12 | importFrom(plyr,llply) 13 | importFrom(plyr,mutate) 14 | importFrom(purrr,compact) 15 | importFrom(purrr,map) 16 | importFrom(purrr,map_dbl) 17 | importFrom(purrr,map_df) 18 | importFrom(readr,read_tsv) 19 | importFrom(stringi,stri_enc_toascii) 20 | importFrom(stringr,fixed) 21 | importFrom(stringr,str_detect) 22 | importFrom(stringr,str_match) 23 | importFrom(stringr,str_replace) 24 | importFrom(stringr,str_replace_all) 25 | importFrom(stringr,str_split) 26 | importFrom(stringr,str_sub) 27 | importFrom(stringr,str_trim) 28 | -------------------------------------------------------------------------------- /NEWS: -------------------------------------------------------------------------------- 1 | # eyelinker 0.1 2 | 3 | Initial CRAN release -------------------------------------------------------------------------------- /R/eyelink_parser.R: -------------------------------------------------------------------------------- 1 | 2 | ##' Read EyeLink ASC file 3 | ##' 4 | ##' ASC files contain raw data from EyeLink eyetrackers (they're ASCII versions of the raw binaries which are themselves in EDF format). 5 | ##' This utility tries to parse the data into something that's usable in R. Please read the EyeLink manual before using it for any serious work, very few checks are done to see if the output makes sense. 6 | ##' read.asc will return data frames containing a "raw" signal as well as event series. Events are either system signals (triggers etc.), which are stored in the "msg" field, or correspond to the Eyelink's interpretation of the eye movement traces (fixations, saccades, blinks). 7 | ##' ASC files are divided into blocks signaled by START and END signals. The block structure is reflected in the "block" field of the dataframes. 8 | ##' If all you have is an EDF file, you need to convert it first using edf2asc from the Eyelink toolbox. 9 | ##' The names of the various columns are the same as the ones used in the Eyelink manual, with two exceptions. "cr.info", which doesn't have a name in the manual, gives you information about corneal reflection tracking. If all goes well its value is just "..." 10 | ##' "remote.info" gives you information about the state of the remote setup, if applicable. It should also be just a bunch of ... values. Refer to the manual for details. 11 | ##' @title Read EyeLink ASC file 12 | ##' @param fname file name 13 | ##' @return a list with components 14 | ##' raw: raw eye positions, velocities, resolution, etc. 15 | ##' msg: messages (no attempt is made to parse them) 16 | ##' fix: fixations 17 | ##' blinks: blinks 18 | ##' sacc: saccades 19 | ##' info: meta-data 20 | ##' 21 | ##' @author Simon Barthelme 22 | ##' @examples 23 | ##' #Example file from SR research that ships with the package 24 | ##' fpath <- system.file("extdata/mono500.asc.gz",package="eyelinker") 25 | ##' dat <- read.asc(fpath) 26 | ##' plot(dat$raw$time,dat$raw$xp,xlab="Time (ms)",ylab="Eye position along x axis (pix)") 27 | ##' @export 28 | read.asc <- function(fname) 29 | { 30 | inp <- readLines(fname) 31 | 32 | #Convert to ASCII 33 | inp <- stri_enc_toascii(inp) 34 | 35 | #Filter out empty lines, comments, trailing whitespace 36 | inp <- str_select(inp,"^\\w*$",reverse=TRUE) %>% str_select("^#",reverse=TRUE) %>% str_select("^/",reverse=TRUE) %>% str_trim(side="right") 37 | 38 | #Read meta-data from the "SAMPLES" line 39 | info <- getInfo(inp) 40 | has.raw <- !is.na(info) 41 | 42 | #Just to spite us, there's an inconsistency in how HTARG info is encoded (missing tab) 43 | #We fix it if necessary 44 | if (has.raw && info$htarg) 45 | { 46 | inp <- str_replace_all(inp,fixed("............."),fixed("\t.............")) 47 | } 48 | 49 | #"Header" isn't strict, it's whatever comes before the first "START" line 50 | init <- str_detect(inp,"^START") %>% which %>% min 51 | header <- inp[1:(init-1)] 52 | inp <- inp[init:length(inp)] 53 | 54 | 55 | #Find blocks 56 | bl.start <- str_detect(inp,"^START")%>%which 57 | bl.end <- str_detect(inp,"^END")%>%which 58 | nBlocks <- length(bl.start) 59 | blocks <- llply(1:nBlocks,function(indB) process.block(inp[bl.start[indB]:bl.end[indB]],info)) 60 | ## collect <- function(vname) 61 | ## { 62 | ## valid <- Filter(function(ind) !is.null(blocks[[ind]][[vname]]),1:length(blocks)) 63 | ## ldply(valid,function(ind) mutate(blocks[[ind]][[vname]],block=ind)) 64 | ## } 65 | collect <- function(vname) 66 | { 67 | #Merge the data from all the different blocks 68 | out <- suppressWarnings(try(map(blocks,vname) %>% compact %>% map_df(identity,.id="block") ,TRUE)) 69 | if (is(out,"try-error")) 70 | { 71 | sprintf("Failed to merge %s",vname) %>% warning 72 | #Merging has failed, return as list 73 | map(blocks,vname) 74 | } 75 | else 76 | { 77 | out 78 | } 79 | } 80 | vars <- c("raw","msg","sacc","fix","blinks","info") 81 | #Collect all the data across blocks 82 | out <- map(vars,collect) %>% setNames(vars) 83 | 84 | out$info <- info 85 | out 86 | } 87 | 88 | 89 | 90 | process.block.header <- function(blk) 91 | { 92 | endh <- str_detect(blk,'^SAMPLES') %>% which 93 | has.samples <- TRUE 94 | #if raw data is missing, then no SAMPLES line 95 | if (length(endh)!=1) 96 | { 97 | endh <- str_detect(blk,'^EVENTS') %>% which 98 | has.samples <- FALSE 99 | } 100 | hd <-blk[1:endh] 101 | #Parse the EVENTS line 102 | ev <- str_select(hd,"^EVENTS") 103 | regex.num <- "([-+]?[0-9]*\\.?[0-9]+)" 104 | srate <-str_match(ev,paste0("RATE\t",regex.num))[,2] %>% as.numeric 105 | tracking <-str_match(ev,"TRACKING\t(\\w+)")[,2] 106 | filter <- str_match(ev,"FILTER\t(\\d)")[,2] %>% as.numeric 107 | events <- list(left=str_detect(ev,fixed("LEFT")), 108 | right=str_detect(ev,fixed("RIGHT")), 109 | res=str_detect(ev,fixed(" RES ")), 110 | tracking=tracking, 111 | srate=srate, 112 | filter=filter) 113 | 114 | if (!has.samples) 115 | { 116 | samples <- NULL 117 | } 118 | else 119 | { 120 | #Now do the same thing for the SAMPLES line 121 | sm <- str_select(hd,"^SAMPLES") 122 | 123 | srate <-str_match(sm,paste0("RATE\t",regex.num))[,2] %>% as.numeric 124 | tracking <-str_match(sm,"TRACKING\t(\\w+)")[,2] 125 | filter <- str_match(sm,"FILTER\t(\\d)")[,2] %>% as.numeric 126 | 127 | samples <- list(left=str_detect(sm,fixed("LEFT")), 128 | right=str_detect(sm,fixed("RIGHT")), 129 | res=str_detect(ev,fixed(" RES ")), 130 | vel=str_detect(ev,fixed(" VEL ")), 131 | tracking=tracking, 132 | srate=srate, 133 | filter=filter) 134 | } 135 | list(events=events,samples=samples,the.rest=blk[-(1:endh)]) 136 | } 137 | 138 | #Turn a list of strings with tab-separated field into a data.frame 139 | tsv2df <- function(dat,coltypes) 140 | { 141 | if (length(dat)==1) 142 | { 143 | dat <- paste0(dat,"\n") 144 | } 145 | else 146 | { 147 | dat <- paste0(dat,collapse="\n") 148 | } 149 | out <- read_tsv(dat,col_names=FALSE,col_types=paste0(coltypes,collapse="")) 150 | ## if (!(is.null(attr(suppressWarnings(out), "problems")))) browser() 151 | out 152 | } 153 | 154 | parse.saccades <- function(evt,events) 155 | { 156 | #Focus only on EFIX events, they contain all the info 157 | esac <- str_select(evt,"^ESAC") %>% str_replace("ESACC\\s+(R|L)","\\1\t") %>% str_replace_all("\t\\s+","\t") 158 | #Missing data 159 | esac <- str_replace_all(esac,"\\s\\.","\tNA") 160 | 161 | df <- str_split(esac,"\n") %>% ldply(function(v) { str_split(v,"\\t")[[1]] }) 162 | #Get a data.frame 163 | if (ncol(df)==10) 164 | { 165 | #ESACC 166 | names(df) <- c("eye","stime","etime","dur","sxp","syp","exp","eyp","ampl","pv") 167 | 168 | } 169 | else if (ncol(df)==12) 170 | { 171 | names(df) <- c("eye","stime","etime","dur","sxp","syp","exp","eyp","ampl","pv","xr","yr") 172 | } 173 | 174 | dfc <- suppressWarnings(llply(as.list(df)[-1],as.numeric) %>% as.data.frame ) 175 | dfc$eye <- df$eye 176 | dfc 177 | } 178 | 179 | 180 | 181 | parse.blinks <- function(evt,events) 182 | { 183 | eblk <- str_select(evt,"^EBLINK") %>% str_replace("EBLINK\\s+(R|L)","\\1\t") %>% str_replace_all("\t\\s+","\t") 184 | #Get a data.frame 185 | #eblk <- eblk %>% tsv2df 186 | df <- str_split(eblk,"\n") %>% ldply(function(v) { str_split(v,"\\t")[[1]] }) 187 | names(df) <- c("eye","stime","etime","dur") 188 | dfc <- suppressWarnings(llply(as.list(df)[-1],as.numeric) %>% as.data.frame ) 189 | dfc$eye <- df$eye 190 | dfc 191 | } 192 | 193 | 194 | 195 | parse.fixations <- function(evt,events) 196 | { 197 | #Focus only on EFIX events, they contain all the info 198 | efix <- str_select(evt,"^EFIX") %>% str_replace("EFIX\\s+(R|L)","\\1\t") %>% str_replace_all("\t\\s+","\t") 199 | #Get a data.frame 200 | #efix <- efix %>% tsv2df 201 | df <- str_split(efix,"\n") %>% ldply(function(v) { str_split(v,"\\t")[[1]] }) 202 | if (ncol(df)==7) 203 | { 204 | names(df) <- c("eye","stime","etime","dur","axp","ayp","aps") 205 | } 206 | else if (ncol(df)==9) 207 | { 208 | names(df) <- c("eye","stime","etime","dur","axp","ayp","aps","xr","yr") 209 | } 210 | dfc <- suppressWarnings(llply(as.list(df)[-1],as.numeric) %>% as.data.frame ) 211 | dfc$eye <- df$eye 212 | dfc 213 | } 214 | 215 | #evt is raw text, events is a structure with meta-data from the START field 216 | process.events <- function(evt,events) 217 | { 218 | #Messages 219 | if (any(str_detect(evt,"^MSG"))) 220 | { 221 | msg <- str_select(evt,"^MSG") %>% str_sub(start=5) %>% str_match("(\\d+)\\s(.*)") 222 | msg <- data.frame(time=as.numeric(msg[,2]),text=msg[,3]) 223 | } 224 | else 225 | { 226 | msg <- c() 227 | } 228 | 229 | fix <- if (str_detect(evt,"^EFIX") %>% any) parse.fixations(evt,events) else NULL 230 | sacc <- if (str_detect(evt,"^ESAC") %>% any) parse.saccades(evt,events) else NULL 231 | blinks <- if (str_detect(evt,"^SBLI") %>% any) parse.blinks(evt,events) else NULL 232 | list(fix=fix,sacc=sacc,msg=msg,blinks=blinks) 233 | } 234 | 235 | 236 | #A block is whatever one finds between a START and an END event 237 | process.block <- function(blk,info) 238 | { 239 | hd <- process.block.header(blk) 240 | blk <- hd$the.rest 241 | if (is.na(info)) #no raw data 242 | { 243 | raw <- NULL 244 | which.raw <- rep(FALSE,length(blk)) 245 | } 246 | else 247 | { 248 | colinfo <- coln.raw(info) 249 | 250 | raw.colnames <- colinfo$names 251 | raw.coltypes <- colinfo$types 252 | 253 | #Get the raw data (lines beginning with a number) 254 | which.raw <- str_detect(blk,'^\\d') 255 | raw <- blk[which.raw] %>% str_select('^\\d') # %>% str_replace(fixed("\t..."),"") 256 | # raw <- str_replace(raw,"\\.+$","") 257 | 258 | #Filter out all the lines where eye position is missing, they're pointless and stored in an inconsistent manner 259 | iscrap <- str_detect(raw, "\\s+\\.\\s+\\.\\s+") 260 | crap <- raw[iscrap] 261 | raw <- raw[!iscrap] 262 | if (length(raw)>0) #We have some data left 263 | { 264 | 265 | #Turn into data.frame 266 | raw <- tsv2df(raw,raw.coltypes) 267 | if (ncol(raw) == length(raw.colnames)) 268 | { 269 | names(raw) <- raw.colnames 270 | } 271 | else 272 | { 273 | warning("Unknown columns in raw data. Assuming first one is time, please check the others") 274 | #names(raw)[1:length(raw.colnames)] <- raw.colnames 275 | names(raw)[1] <- "time" 276 | } 277 | nCol <- ncol(raw) 278 | if (any(iscrap)) 279 | { 280 | crapmat <- matrix(NA,length(crap),nCol) 281 | crapmat[,1] <- as.numeric(str_match(crap,"^(\\d+)")[,1]) 282 | crapmat <- as.data.frame(crapmat) 283 | names(crapmat) <- names(raw) 284 | raw <- rbind(raw,crapmat) 285 | raw <- raw[order(raw$time),] 286 | } 287 | } 288 | else 289 | { 290 | warning("All data are missing in current block") 291 | raw <- NULL 292 | } 293 | } 294 | #The events (lines not beginning with a number) 295 | evt <- blk[!which.raw] 296 | res <- process.events(evt,hd$events) 297 | res$raw <- raw 298 | res$sampling.rate <- hd$events$srate 299 | res$left.eye <- hd$events$left 300 | res$right.eye <- hd$events$right 301 | res 302 | } 303 | 304 | #Read some meta-data from the SAMPLES line 305 | #Inspired by similar code from cili library by Ben Acland 306 | getInfo <- function(inp) 307 | { 308 | info <- list() 309 | #Find the "SAMPLES" line 310 | l <- str_select(inp,"^SAMPLES") 311 | if (length(l)>0) 312 | { 313 | l <- l[[1]] 314 | info$velocity <- str_detect(l,fixed("VEL")) 315 | info$resolution <- str_detect(l,fixed("RES")) 316 | #Even in remote setups, the target information may not be recorded 317 | #e.g.: binoRemote250.asc 318 | #so we make sure it actually is 319 | info$htarg <- FALSE 320 | if (str_detect(l,fixed("HTARG"))) 321 | { 322 | #Normally the htarg stuff is just twelve dots in a row, but in case there are errors we need 323 | #the following regexp. 324 | pat <- "(M|\\.)(A|\\.)(N|\\.)(C|\\.)(F|\\.)(T|\\.)(B|\\.)(L|\\.)(R|\\.)(T|\\.)(B|\\.)(L|\\.)(R|\\.)" 325 | info$htarg <- str_detect(inp,pat) %>% any 326 | } 327 | info$input <- str_detect(l,fixed("INPUT")) 328 | info$left <- str_detect(l,fixed("LEFT")) 329 | info$right <- str_detect(l,fixed("RIGHT")) 330 | info$cr <- str_detect(l,fixed("CR")) 331 | info$mono <- !(info$right & info$left) 332 | } 333 | else #NO SAMPLES!!! 334 | { 335 | info <- NA 336 | } 337 | info 338 | } 339 | 340 | #Column names for the raw data 341 | coln.raw <- function(info) 342 | { 343 | eyev <- c("xp","yp","ps") 344 | ctype <- rep("d",3) 345 | if (info$velocity) 346 | { 347 | eyev <- c(eyev,"xv","yv") 348 | ctype <- c(ctype,rep("d",2)) 349 | } 350 | if (info$resolution) 351 | { 352 | eyev <- c(eyev,"xr","yr") 353 | ctype <- c(ctype,rep("d",2)) 354 | } 355 | 356 | if (!info$mono) 357 | { 358 | eyev <- c(paste0(eyev,"l"),paste0(eyev,"r")) 359 | ctype <- rep(ctype,2) 360 | } 361 | 362 | #With corneal reflections we need an extra column 363 | if (info$cr) 364 | { 365 | eyev <- c(eyev,"cr.info") 366 | ctype <- c(ctype,"c") 367 | } 368 | 369 | #Three extra columns for remote set-up 370 | if (info$htarg) 371 | { 372 | eyev <- c(eyev,"tx","ty","td","remote.info") 373 | ctype <- c(ctype,c("d","d","d","c")) 374 | } 375 | 376 | 377 | list(names=c("time",eyev),types=c("i",ctype)) 378 | } 379 | -------------------------------------------------------------------------------- /R/eyelinker.R: -------------------------------------------------------------------------------- 1 | #' eyelinker: read raw data from Eyelink eyetrackers 2 | #' 3 | #' Eyelink eye trackers output a horrible mess, typically under 4 | #' the form of an .asc file. The file in question is an assorted collection of 5 | #' messages, events and raw data. This R package will attempt to make sense of it. 6 | #' 7 | #' The main function in the package is read.asc. 8 | #' @docType package 9 | #' @name eyelinker 10 | NULL 11 | 12 | #' @importFrom stringr str_match str_split str_sub str_trim str_replace str_replace_all str_detect fixed 13 | #' @importFrom purrr map map_dbl map_df compact 14 | #' @importFrom readr read_tsv 15 | #' @importFrom plyr llply dlply ldply mutate 16 | #' @importFrom stringi stri_enc_toascii 17 | #' @importFrom magrittr "%>%" 18 | #' @importFrom intervals which_nearest distance_to_nearest Intervals 19 | NULL 20 | -------------------------------------------------------------------------------- /R/utils.R: -------------------------------------------------------------------------------- 1 | ##' From a set of intervals, find which interval values belong to 2 | ##' 3 | ##' This utility function is a replacement for findIntervals that works even when the set of intervals is discontinuous. It wraps "which_nearest" from the intervals package. 4 | ##' @param x a set of numeric values 5 | ##' @param Intv a two-column matrix or an object of class Intervals 6 | ##' @return for each value in x: if x[i] in in the set of intervals, the index of the corresponding interval(s), NA if no interval contains x[i] 7 | ##' @seealso `%In%` 8 | ##' @examples 9 | ##' start <- c(0,1,2) 10 | ##' end <- c(.5,1.3,3) 11 | ##' intv <- cbind(start,end) #The first interval is 0-0.5, second is 1-1.3, etc. 12 | ##' whichInterval(seq(0,3,l=10),intv) 13 | ##' @author Simon Barthelme 14 | ##' @export 15 | whichInterval <- function(x,Intv) 16 | { 17 | if (is.integer(x)) x <- as.double(x) 18 | if (is.matrix(Intv)) 19 | { 20 | Intv <- Intervals(Intv) 21 | } 22 | wn <- which_nearest(x,Intv) 23 | notFound <- wn$distance_to_nearest!=0 24 | if (any(notFound)) 25 | { 26 | wn[notFound,]$which_nearest <- NA 27 | } 28 | #Check if we can simplify output 29 | if (all(sapply(wn$which_nearest,length)==1)) 30 | { 31 | wn$which_nearest <- do.call('c',wn$which_nearest) 32 | } 33 | wn$which_nearest 34 | } 35 | ##' Find if value belongs to a set of intervals 36 | ##' 37 | ##' Wrapper around distance_to_nearest from the Intervals package. 38 | ##' @param x a set of numeric values 39 | ##' @param Intv a set of intervals, defined by a two-column matrix of endpoints or an Intervals object 40 | ##' @return a vector of logicals, which are true if x[i] belongs to any of the intervals in the set. 41 | ##' @author Simon Barthelme 42 | ##' @examples 43 | ##' start <- c(0,1,2) 44 | ##' end <- c(.5,1.3,3) 45 | ##' intv <- cbind(start,end) #The first interval is 0-0.5, second is 1-1.3, etc. 46 | ##' c(0,.6,1.5,3) %In% intv 47 | ##' @export 48 | `%In%` <- function(x,Intv) 49 | { 50 | if (is.integer(x)) x <- as.double(x) 51 | if (is.matrix(Intv)) 52 | { 53 | Intv <- Intervals(Intv) 54 | } 55 | distance_to_nearest(x,Intv) == 0 56 | } 57 | 58 | str_select <- function(s,p,reverse=FALSE) 59 | { 60 | k <- str_detect(s,p) 61 | if (reverse) k <- !k 62 | s[k] 63 | } 64 | 65 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![CRAN Version](http://www.r-pkg.org/badges/version/eyelinker)](https://cran.rstudio.com/web/packages/eyelinker) 2 | 3 | # Eyelinker: a package for reading Eyelink data 4 | 5 | Turns horrible Eyelink .asc files into less horrible R data structures. 6 | 7 | install.packages("eyelinker") 8 | library(eyelinker) 9 | #Example file from SR research that ships with the package 10 | fpath <- system.file("extdata/mono500.asc.gz",package="eyelinker") 11 | dat <- read.asc(fpath) 12 | plot(dat$raw$time,dat$raw$xp,xlab="Time (ms)",ylab="Eye position along x axis (pix)") 13 | #For more info: 14 | vignette("basics",package="eyelinker") 15 | 16 | Author: Simon Barthelmé, CNRS, Gipsa-lab. See also: [cili](https://github.com/beOn/cili). 17 | 18 | -------------------------------------------------------------------------------- /inst/extdata/bino1000.asc.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dahtah/eyelinker/f1cf282ea19fe1b9b827247197781ff50a445064/inst/extdata/bino1000.asc.gz -------------------------------------------------------------------------------- /inst/extdata/bino250.asc.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dahtah/eyelinker/f1cf282ea19fe1b9b827247197781ff50a445064/inst/extdata/bino250.asc.gz -------------------------------------------------------------------------------- /inst/extdata/bino500.asc.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dahtah/eyelinker/f1cf282ea19fe1b9b827247197781ff50a445064/inst/extdata/bino500.asc.gz -------------------------------------------------------------------------------- /inst/extdata/binoRemote250.asc.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dahtah/eyelinker/f1cf282ea19fe1b9b827247197781ff50a445064/inst/extdata/binoRemote250.asc.gz -------------------------------------------------------------------------------- /inst/extdata/binoRemote500.asc.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dahtah/eyelinker/f1cf282ea19fe1b9b827247197781ff50a445064/inst/extdata/binoRemote500.asc.gz -------------------------------------------------------------------------------- /inst/extdata/mono1000.asc.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dahtah/eyelinker/f1cf282ea19fe1b9b827247197781ff50a445064/inst/extdata/mono1000.asc.gz -------------------------------------------------------------------------------- /inst/extdata/mono2000.asc.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dahtah/eyelinker/f1cf282ea19fe1b9b827247197781ff50a445064/inst/extdata/mono2000.asc.gz -------------------------------------------------------------------------------- /inst/extdata/mono250.asc.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dahtah/eyelinker/f1cf282ea19fe1b9b827247197781ff50a445064/inst/extdata/mono250.asc.gz -------------------------------------------------------------------------------- /inst/extdata/mono500.asc.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dahtah/eyelinker/f1cf282ea19fe1b9b827247197781ff50a445064/inst/extdata/mono500.asc.gz -------------------------------------------------------------------------------- /inst/extdata/monoRemote250.asc.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dahtah/eyelinker/f1cf282ea19fe1b9b827247197781ff50a445064/inst/extdata/monoRemote250.asc.gz -------------------------------------------------------------------------------- /inst/extdata/monoRemote500.asc.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dahtah/eyelinker/f1cf282ea19fe1b9b827247197781ff50a445064/inst/extdata/monoRemote500.asc.gz -------------------------------------------------------------------------------- /man/eyelinker.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/eyelinker.R 3 | \docType{package} 4 | \name{eyelinker} 5 | \alias{eyelinker} 6 | \alias{eyelinker-package} 7 | \title{eyelinker: read raw data from Eyelink eyetrackers} 8 | \description{ 9 | Eyelink eye trackers output a horrible mess, typically under 10 | the form of an .asc file. The file in question is an assorted collection of 11 | messages, events and raw data. This R package will attempt to make sense of it. 12 | } 13 | \details{ 14 | The main function in the package is read.asc. 15 | } 16 | 17 | -------------------------------------------------------------------------------- /man/grapes-In-grapes.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/utils.R 3 | \name{\%In\%} 4 | \alias{\%In\%} 5 | \title{Find if value belongs to a set of intervals} 6 | \usage{ 7 | x \%In\% Intv 8 | } 9 | \arguments{ 10 | \item{x}{a set of numeric values} 11 | 12 | \item{Intv}{a set of intervals, defined by a two-column matrix of endpoints or an Intervals object} 13 | } 14 | \value{ 15 | a vector of logicals, which are true if x[i] belongs to any of the intervals in the set. 16 | } 17 | \description{ 18 | Wrapper around distance_to_nearest from the Intervals package. 19 | } 20 | \examples{ 21 | start <- c(0,1,2) 22 | end <- c(.5,1.3,3) 23 | intv <- cbind(start,end) #The first interval is 0-0.5, second is 1-1.3, etc. 24 | c(0,.6,1.5,3) \%In\% intv 25 | } 26 | \author{ 27 | Simon Barthelme 28 | } 29 | 30 | -------------------------------------------------------------------------------- /man/read.asc.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/eyelink_parser.R 3 | \name{read.asc} 4 | \alias{read.asc} 5 | \title{Read EyeLink ASC file} 6 | \usage{ 7 | read.asc(fname) 8 | } 9 | \arguments{ 10 | \item{fname}{file name} 11 | } 12 | \value{ 13 | a list with components 14 | raw: raw eye positions, velocities, resolution, etc. 15 | msg: messages (no attempt is made to parse them) 16 | fix: fixations 17 | blinks: blinks 18 | sacc: saccades 19 | info: meta-data 20 | } 21 | \description{ 22 | Read EyeLink ASC file 23 | } 24 | \details{ 25 | ASC files contain raw data from EyeLink eyetrackers (they're ASCII versions of the raw binaries which are themselves in EDF format). 26 | This utility tries to parse the data into something that's usable in R. Please read the EyeLink manual before using it for any serious work, very few checks are done to see if the output makes sense. 27 | read.asc will return data frames containing a "raw" signal as well as event series. Events are either system signals (triggers etc.), which are stored in the "msg" field, or correspond to the Eyelink's interpretation of the eye movement traces (fixations, saccades, blinks). 28 | ASC files are divided into blocks signaled by START and END signals. The block structure is reflected in the "block" field of the dataframes. 29 | If all you have is an EDF file, you need to convert it first using edf2asc from the Eyelink toolbox. 30 | The names of the various columns are the same as the ones used in the Eyelink manual, with two exceptions. "cr.info", which doesn't have a name in the manual, gives you information about corneal reflection tracking. If all goes well its value is just "..." 31 | "remote.info" gives you information about the state of the remote setup, if applicable. It should also be just a bunch of ... values. Refer to the manual for details. 32 | } 33 | \examples{ 34 | #Example file from SR research that ships with the package 35 | fpath <- system.file("extdata/mono500.asc.gz",package="eyelinker") 36 | dat <- read.asc(fpath) 37 | plot(dat$raw$time,dat$raw$xp,xlab="Time (ms)",ylab="Eye position along x axis (pix)") 38 | } 39 | \author{ 40 | Simon Barthelme 41 | } 42 | 43 | -------------------------------------------------------------------------------- /man/whichInterval.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/utils.R 3 | \name{whichInterval} 4 | \alias{whichInterval} 5 | \title{From a set of intervals, find which interval values belong to} 6 | \usage{ 7 | whichInterval(x, Intv) 8 | } 9 | \arguments{ 10 | \item{x}{a set of numeric values} 11 | 12 | \item{Intv}{a two-column matrix or an object of class Intervals} 13 | } 14 | \value{ 15 | for each value in x: if x[i] in in the set of intervals, the index of the corresponding interval(s), NA if no interval contains x[i] 16 | } 17 | \description{ 18 | This utility function is a replacement for findIntervals that works even when the set of intervals is discontinuous. It wraps "which_nearest" from the intervals package. 19 | } 20 | \examples{ 21 | start <- c(0,1,2) 22 | end <- c(.5,1.3,3) 23 | intv <- cbind(start,end) #The first interval is 0-0.5, second is 1-1.3, etc. 24 | whichInterval(seq(0,3,l=10),intv) 25 | } 26 | \author{ 27 | Simon Barthelme 28 | } 29 | \seealso{ 30 | `%In%` 31 | } 32 | 33 | -------------------------------------------------------------------------------- /tests/testthat.R: -------------------------------------------------------------------------------- 1 | library(testthat) 2 | library(eyelinker) 3 | 4 | test_check("eyelinker") 5 | -------------------------------------------------------------------------------- /tests/testthat/test_examples.R: -------------------------------------------------------------------------------- 1 | context("SR research test files") 2 | 3 | test_that("test_files_load",{ 4 | files <- c('bino1000.asc.gz','bino250.asc.gz','bino500.asc.gz','binoRemote250.asc.gz','binoRemote500.asc.gz','mono1000.asc.gz','mono2000.asc.gz','mono250.asc.gz','mono500.asc.gz','monoRemote250.asc.gz','monoRemote500.asc.gz') 5 | for (f in files) 6 | { 7 | fpath <- system.file(paste0("extdata/",f),package="eyelinker") 8 | tst <- read.asc(fpath) 9 | expect_equal(sort(names(tst)),c("blinks", "fix","info","msg","raw", "sacc" )) 10 | } 11 | }) 12 | 13 | -------------------------------------------------------------------------------- /vignettes/basics.Rmd: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Loading Eyelink data with eyelinker" 3 | author: "Simon Barthelmé" 4 | date: "`r Sys.Date()`" 5 | output: rmarkdown::html_vignette 6 | vignette: > 7 | %\VignetteIndexEntry{Loading Eyelink data with eyelinker} 8 | %\VignetteEngine{knitr::rmarkdown} 9 | %\VignetteEncoding{UTF-8} 10 | --- 11 | 12 | We'll use test data supplied by SR Research (which I found in the cili package for Python). The test data can be found in the extdata/ directory of the package. 13 | 14 | ```{r results="hide",message=FALSE} 15 | require(eyelinker) 16 | require(dplyr) 17 | 18 | #Look for file 19 | fpath <- system.file("extdata/mono500.asc.gz",package="eyelinker") 20 | ``` 21 | 22 | asc files can be gigantic, so it's a good idea to compress them, R doesn't mind (here they're compressed in gzip format, hence the .gz). 23 | 24 | To read the file just call read.asc: 25 | 26 | ```{r} 27 | dat <- read.asc(fpath) 28 | ``` 29 | 30 | dat is a list with fields: 31 | 32 | ```{r} 33 | names(dat) 34 | ``` 35 | 36 | - raw is the raw data (eye position, velocity, etc.) as a function of time 37 | - sac are the saccade events as labelled by the Eyelink 38 | - fix are the fixations 39 | - blinks are the blinks 40 | - msg are message events 41 | - info contains some meta-data 42 | 43 | ## Meta-data 44 | 45 | Some meta-data can be read from the "SAMPLES" lines in the asc file. 46 | 47 | ```{r} 48 | str(dat$info) 49 | ``` 50 | 51 | - velocity: true if data contains eye velocity 52 | - resolution: true if data contains resolution 53 | - cr: true if corneal reflection mode is used 54 | - htarg: true if data contains remote info (only applicable in remote setup) 55 | - input: true if data contains input info 56 | - left: true if left eye is recorded 57 | - right: true if right eye is recorded 58 | - mono: true if recording is monocular 59 | 60 | Here we have a monocular recording of the left eye. 61 | 62 | ## What are the units? 63 | 64 | Depending on how the Eyelink is set up, positions can be reported in pixels or degrees, relative to the head, the screen or the camera. 65 | I'm guessing the most common case is to use screen coordinates, but I don't know whether the coordinate system is stored in a predictable manner in asc files. If you have any suggestions please email me. 66 | I'll assume you know what the relevant units are. 67 | 68 | ## Raw data 69 | 70 | The raw data has a simple structure: 71 | 72 | ```{r} 73 | raw <- dat$raw 74 | head(raw,3) 75 | ``` 76 | 77 | - time is a time stamp (ms) 78 | - xp, yp: x and y position of the recorded eye 79 | - ps: pupil size (arb. units) 80 | - cr.info: status of corneal reflection tracking. "..." means all's well. See manual for more. 81 | - block: the .asc file is divided into START and END blocks, and the block variable indexes them. 82 | 83 | In a binocular recording the raw data has the following structure: 84 | 85 | ```{r} 86 | dat.bi <- system.file("extdata/bino1000.asc.gz",package="eyelinker") %>% read.asc 87 | 88 | head(dat.bi$raw,3) 89 | ``` 90 | 91 | The variables are the same as before, with the addition of a postfix corresponding to the eye (i.e. xpl is the x position of the left eye). 92 | 93 | 94 | ## Tidying up raw data 95 | 96 | It's sometimes more convenient for plotting and analysis if the raw data are in "long" rather than "wide" format, as in the following example: 97 | 98 | ```{r} 99 | library(tidyr) 100 | 101 | raw.long <- dplyr::select(raw,time,xp,yp,block) %>% gather("coord","pos",xp,yp) 102 | head(raw.long,2) 103 | tail(raw.long,2) 104 | ``` 105 | 106 | The eye position is now in a single column rather than two, and the column "coord" tells us if the valuye corresponds to the x or y position. The benefits may not be obvious now, but it does make plotting the traces via ggplot2 a lot easier: 107 | 108 | 109 | ```{r fig.width=5, fig.height=5} 110 | require(ggplot2) 111 | raw.long <- mutate(raw.long,ts=(time-min(time))/1e3) #let's have time in sec. 112 | ggplot(raw.long,aes(ts,pos,col=coord))+geom_point() 113 | ``` 114 | 115 | In this particular file there are four separate recording periods, corresponding to different "blocks" in the asc file, which we can check using: 116 | 117 | ```{r fig.width=5, fig.height=5} 118 | ggplot(raw.long,aes(ts,pos,col=coord))+geom_line()+facet_wrap(~ block) 119 | ``` 120 | 121 | ## Saccades 122 | 123 | The Eyelink automatically detects saccades in an online fashion. The results are converted to a data.frame: 124 | 125 | ```{r } 126 | sac <- dat$sac 127 | head(sac,2) 128 | ``` 129 | 130 | Each line corresponds to a saccade, and the different columns are: 131 | 132 | - stime and etime: the start and end times of the saccade 133 | - dur: duration (ms) 134 | - sxp, yxp: starting position 135 | - exp, eyp: end position 136 | - ampl: saccade amplitude 137 | - pv: peak velocity 138 | - block: see above 139 | 140 | In the binocular case, we have: 141 | 142 | ```{r} 143 | head(dat.bi$sac,3) 144 | ``` 145 | 146 | The only difference is in the "eye" column, which tells you in which eye the saccade was first recorded. 147 | 148 | ## Labelling saccades in the raw traces 149 | 150 | To see if the saccades have been labelled correctly, we'll have to find the corresponding time samples in the raw data. 151 | 152 | The easiest way to achieve this is to view the detected saccades as a set of temporal intervals, with endpoints given by stime and etime. We'll use function "%In%" to check if each time point in the raw data can be found in one of these intervals. 153 | 154 | ```{r} 155 | Sac <- cbind(sac$stime,sac$etime) #Define a set of intervals with these endpoints 156 | #See also: intervals package 157 | raw <- mutate(raw,saccade=time %In% Sac) 158 | head(raw,3) 159 | mean(raw$saccade)*100 #6% of time samples correspond to saccades 160 | ``` 161 | 162 | Now each time point labelled with "saccade==TRUE" corresponds to a saccade detected by the eye tracker. 163 | 164 | Let's plot traces again: 165 | 166 | ```{r fig.width=5, fig.height=5} 167 | mutate(raw.long,saccade=time %In% Sac) %>% filter(block==1) %>% ggplot(aes(ts,pos,group=coord,col=saccade))+geom_line() 168 | ``` 169 | 170 | 171 | 172 | ## Fixations 173 | 174 | Fixations are stored in a very similar way to saccades: 175 | 176 | ```{r} 177 | fix <- dat$fix 178 | head(fix,3) 179 | ``` 180 | 181 | Each line is a fixation, and the columns are: 182 | 183 | - stime and etime: the start and end times of the fixation 184 | - dur: duration (ms) 185 | - axp, ayp: average eye position during fixation 186 | - aps: average pupil size during fixation 187 | 188 | ## Labelling fixations in the raw traces 189 | 190 | We can re-use essentially the same code to label fixations as we did to label saccades: 191 | 192 | ```{r fig.width=5, fig.height=5} 193 | Fix <- cbind(fix$stime,fix$etime) #Define a set of intervals 194 | mutate(raw.long,fixation=time %In% Fix) %>% filter(block==1) %>% ggplot(aes(ts,pos,group=coord,col=fixation))+geom_line() 195 | ``` 196 | 197 | 198 | We can get a fixation index using whichInterval: 199 | 200 | ```{r} 201 | mutate(raw,fix.index=whichInterval(time,Fix)) %>% head(4) 202 | ``` 203 | 204 | Let's check that the average x and y positions are correct: 205 | 206 | ```{r} 207 | raw <- mutate(raw,fix.index=whichInterval(time,Fix)) 208 | fix.check <- filter(raw,!is.na(fix.index)) %>% group_by(fix.index) %>% summarise(axp=mean(xp),ayp=mean(yp)) %>% ungroup 209 | head(fix.check,3) 210 | ``` 211 | 212 | We grouped all time samples according to fixation index, and computed mean x and y positions. 213 | 214 | We verify that we recovered the right values: 215 | 216 | ```{r} 217 | all.equal(fix.check$axp,fix$axp) 218 | all.equal(fix.check$ayp,fix$ayp) 219 | ``` 220 | 221 | ## Blinks 222 | 223 | Blinks are detected automatically, and stored similarly to saccades and fixations. We load a different dataset: 224 | 225 | ```{r} 226 | fpath <- system.file("extdata/monoRemote500.asc.gz",package="eyelinker") 227 | dat <- read.asc(fpath) 228 | dat$blinks 229 | ``` 230 | 231 | The fields should be self-explanatory. We'll re-use some the code above to label the blinks: 232 | 233 | ```{r} 234 | Blk <- cbind(dat$blinks$stime,dat$blinks$etime) #Define a set of intervals 235 | 236 | filter(dat$raw,time %In% Blk) %>% head 237 | ``` 238 | 239 | Not surprisingly, during blinks, eye position data is unavailable. Unfortunately, it takes the eyetracker a bit of time to detect blinks, and the eye position data around blinks may be suspect. The eyelink manual suggests that getting rid of samples that are within 100ms of a blink should eliminate most problems. We'll use some functions from package *intervals* to expand our blinks by 100ms: 240 | 241 | ```{r} 242 | require(intervals) 243 | Suspect <- Intervals(Blk) %>% expand(100,"absolute") 244 | Suspect 245 | ``` 246 | 247 | Here's an example of a trace around a blink: 248 | 249 | ```{r fig.width=5, fig.height=5} 250 | raw.long <- dplyr::select(dat$raw,time,xp,yp,block) %>% gather("coord","pos",xp,yp) 251 | raw.long <- mutate(raw.long,ts=(time-min(time))/1e3) #let's have time in sec. 252 | ex <- mutate(raw.long,suspect=time %In% Suspect) %>% filter(block==2) 253 | ggplot(ex,aes(ts,pos,group=coord,col=suspect))+geom_line()+coord_cartesian(xlim=c(34,40))+labs(x="time (s)") 254 | ``` 255 | 256 | The traces around the blink are indeed spurious. 257 | 258 | ## Messages 259 | 260 | The last data structure we need to cover contains messages: 261 | 262 | ```{r} 263 | head(dat$msg) 264 | ``` 265 | 266 | The lines correspond to "MSG" lines in the original asc file. Since messages can be anything read.asc leaves them unparsed. If you're interested in certain event types (e.g., time stamps), you'll have to parse msg$text yourself. 267 | Here for example we extract all messages that contain the words "Saccade_target": 268 | 269 | ```{r} 270 | library(stringr) 271 | filter(dat$msg,str_detect(text,fixed("blank_screen"))) 272 | ``` 273 | 274 | --------------------------------------------------------------------------------