Skip to content

Commit

Permalink
Merge pull request #53 from TheD2Lab/40-saccade-v
Browse files Browse the repository at this point in the history
Fix saccade velocity sorting
  • Loading branch information
ashkjones authored Aug 7, 2024
2 parents 19d3e51 + 0e12cd9 commit d2a3c68
Show file tree
Hide file tree
Showing 6 changed files with 199 additions and 166 deletions.
139 changes: 75 additions & 64 deletions src/main/java/com/github/thed2lab/analysis/Analysis.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,38 +7,42 @@
import java.util.List;

public class Analysis {
final static int SCREEN_WIDTH = 1920;
final static int SCREEN_HEIGHT = 1080;

final static int MIN_PATTERN_LENGTH = 3;
final static int MAX_PATTERN_LENGTH = 7;
final static int MIN_PATTERN_FREQUENCY = 2;
final static int MIN_SEQUENCE_SIZE = 3;

final static String TIME_INDEX = "TIME";
/**
* The analysis class drives the entire analysis on one or multiple files of gaze data.
* Its role is to iterate over each file, and process it into DataEntry objects for gaze,
* validity, and fixations.
*/
private final static int SCREEN_WIDTH = 1920;
private final static int SCREEN_HEIGHT = 1080;

private final static int MIN_PATTERN_LENGTH = 3;
private final static int MAX_PATTERN_LENGTH = 7;
private final static int MIN_PATTERN_FREQUENCY = 2;
private final static int MIN_SEQUENCE_SIZE = 3;

private Parameters params;

/**
* Used to construct a single Analysis object.
* @param params information about the files to analyze and the types of analysis run.
*/
public Analysis(Parameters params) {
this.params = params;
}

/**
* Runs all data analysis and writes the results to files. The method iterates over all the provided files,
* create DataEntry objects to represent them, and call methods to analyze those DataEntry objects.
* Finally, the results of these methods are output to CSV files.
* @return {@code Boolean} indicating if the run was successful.
*/
public boolean run() {
try {
File[] inputFiles = params.getInputFiles();
List<String> sequences = new ArrayList<String>();
List<List<String>> allParticipantDGMs = new ArrayList<List<String>>();
LinkedHashMap<String, Integer> aoiMap = new LinkedHashMap<String, Integer>();

// aoiMap.put("", 65);
// aoiMap.put("Alt_VSI", 66);
// aoiMap.put("AI", 67);
// aoiMap.put("TI_HSI", 68);
// aoiMap.put("SSI", 69);
// aoiMap.put("ASI", 70);
// aoiMap.put("RPM", 71);
// aoiMap.put("Window", 72);

WindowSettings settings = params.getWindowSettings();

for (int i = 0; i < inputFiles.length; i++) {
Expand All @@ -61,7 +65,7 @@ public boolean run() {
fixations.writeToCSV(pDirectory, pName + "_fixations");

// Generate DGMs
ArrayList<List<String>> descriptiveGazeMeasures = generateResults(allGaze, fixations);
List<List<String>> descriptiveGazeMeasures = generateResults(allGaze, fixations);
FileHandler.writeToCSV(descriptiveGazeMeasures, pDirectory, pName + "_DGMs");

// If empty, add header row
Expand Down Expand Up @@ -138,53 +142,60 @@ public boolean run() {
}

// This function should only take in raw gaze data as a parameter, otherwise derived DataEntrys will be produced with incorrect data
public static ArrayList<List<String>> generateResults(DataEntry allGaze, DataEntry fixations) {
/**
* Generates descriptive gaze measures from a single participant’s raw gaze data for the whole screen.
* @param allGaze all the participant's gaze data.
* @param fixations the participant gaze data, filtered by fixations.
* @return a {@code List<List<String>} where the first inner-list is the measure names, and second inner-list is the calculated values.
*/
static List<List<String>> generateResults(DataEntry allGaze, DataEntry fixations) {
DataEntry validGaze = DataFilter.applyScreenSize(DataFilter.filterByValidity(allGaze), SCREEN_WIDTH, SCREEN_HEIGHT);
DataEntry validFixations = DataFilter.applyScreenSize(DataFilter.filterByValidity(fixations), SCREEN_WIDTH, SCREEN_HEIGHT);

// DataEntry validGaze = DataFilter.applyScreenSize(allGaze, SCREEN_WIDTH, SCREEN_HEIGHT);
// DataEntry validFixations = DataFilter.applyScreenSize(fixations, SCREEN_WIDTH, SCREEN_HEIGHT);

ArrayList<List<String>> results = new ArrayList<List<String>>();
results.add(new ArrayList<String>()); //Headers
results.add(new ArrayList<String>()); //Values

LinkedHashMap<String,String> fixation = Fixations.analyze(validFixations);
results.get(0).addAll(fixation.keySet());
results.get(1).addAll(fixation.values());

LinkedHashMap<String,String> saccades = Saccades.analyze(validFixations);
results.get(0).addAll(saccades.keySet());
results.get(1).addAll(saccades.values());

LinkedHashMap<String, String> saccadeVelocity = SaccadeVelocity.analyze(validGaze);
results.get(0).addAll(saccadeVelocity.keySet());
results.get(1).addAll(saccadeVelocity.values());

LinkedHashMap<String,String> angles = Angles.analyze(validFixations);
results.get(0).addAll(angles.keySet());
results.get(1).addAll(angles.values());

LinkedHashMap<String,String> convexHull = ConvexHull.analyze(validFixations);
results.get(0).addAll(convexHull.keySet());
results.get(1).addAll(convexHull.values());

LinkedHashMap<String,String> entropy = GazeEntropy.analyze(validFixations);
results.get(0).addAll(entropy.keySet());
results.get(1).addAll(entropy.values());

LinkedHashMap<String, String> blinks = Blinks.analyze(allGaze);
results.get(0).addAll(blinks.keySet());
results.get(1).addAll(blinks.values());

LinkedHashMap<String,String> gaze = Gaze.analyze(validGaze);
results.get(0).addAll(gaze.keySet());
results.get(1).addAll(gaze.values());

LinkedHashMap<String,String> event = Event.analyze(validGaze);
results.get(0).addAll(event.keySet());
results.get(1).addAll(event.values());
DataEntry validAoiFixations = DataFilter.applyScreenSize(DataFilter.filterByValidity(fixations), SCREEN_WIDTH, SCREEN_HEIGHT);
var results = generateResultsHelper(validGaze, validGaze, validAoiFixations);
return results;
}

// This function should only take in raw gaze data as a parameter, otherwise derived DataEntrys will be produced with incorrect data
/**
* Generates descriptive gaze measures from a single participant’s raw gaze data for a single AOI.
* @param allGaze all the of participant gaze data.
* @param aoiGaze the participant gaze data that occurred inside the target AOI.
* @param aoiFixations the participant gaze data, filtered by fixations that occurred inside the target AOI.
* @return a {@code List<List<String>} where the first inner-list is the measure names, and second inner-list is the calculated values.
*/
static List<List<String>> generateResults(DataEntry allGaze, DataEntry aoiGaze, DataEntry aoiFixations) {
DataEntry validAllGaze = DataFilter.applyScreenSize(DataFilter.filterByValidity(allGaze), SCREEN_WIDTH, SCREEN_HEIGHT);
DataEntry validAoiGaze = DataFilter.applyScreenSize(DataFilter.filterByValidity(allGaze), SCREEN_WIDTH, SCREEN_HEIGHT);
DataEntry validAoiFixations = DataFilter.applyScreenSize(DataFilter.filterByValidity(aoiFixations), SCREEN_WIDTH, SCREEN_HEIGHT);
var results = generateResultsHelper(validAllGaze, validAoiGaze, validAoiFixations);
return results;
}

/**
* Helper methods that generates the descriptive gaze measures.
* @param validAllGaze all gaze data, filtered by validity.
* @param validAoiGaze the gaze data that ocurred within an aoi, filtered by validity. For the whole screen, this is the same
* data as validAllGaze.
* @param validAoiFixation the gaze data that ocurred within an aoi, filtered by fixation and validity.
* @return a list the descriptive gaze measures where the first row is the headers and the second row is the values.
*/
private static List<List<String>> generateResultsHelper(DataEntry validAllGaze, DataEntry validAoiGaze, DataEntry validAoiFixation) {

LinkedHashMap<String,String> resultsMap = new LinkedHashMap<String, String>();
resultsMap.putAll(Fixations.analyze(validAoiFixation));
resultsMap.putAll(Saccades.analyze(validAoiFixation));
resultsMap.putAll(SaccadeVelocity.analyze(validAllGaze, validAoiFixation));
resultsMap.putAll(Angles.analyze(validAoiFixation));
resultsMap.putAll(ConvexHull.analyze(validAoiFixation));
resultsMap.putAll(GazeEntropy.analyze(validAoiFixation));
resultsMap.putAll(Blinks.analyze(validAoiGaze));
resultsMap.putAll(Gaze.analyze(validAoiGaze));
resultsMap.putAll(Event.analyze(validAoiGaze));

var resultsList = new ArrayList<List<String>>(2);
resultsList.add(new ArrayList<>(resultsMap.keySet()));
resultsList.add(new ArrayList<>(resultsMap.values()));

return resultsList;
}
}
52 changes: 17 additions & 35 deletions src/main/java/com/github/thed2lab/analysis/AreaOfInterests.java
Original file line number Diff line number Diff line change
Expand Up @@ -64,49 +64,31 @@ public static void generateAOIs(DataEntry allGazeData, String outputDirectory, S
boolean isFirst = true;
Set<String> aoiKeySet = aoiMetrics.keySet();

//int aoiFixationCount = 0;
int row = 1;
for (String aoiKey : aoiKeySet) {
DataEntry aoi = aoiMetrics.get(aoiKey);
DataEntry aoiFixations = aoiFixationMetrics.get(aoiKey);
//aoiFixationCount += aoiFixations.rowCount();

aoi.writeToCSV(outputDirectory + "/AOIs", aoiKey + "_all_gaze");

// if (aoi.rowCount() >= 2) {
ArrayList<List<String>> results = Analysis.generateResults(aoi, aoiFixations);
if (isFirst) { //
isFirst = false;
List<String> headers = results.get(0);
metrics.get(0).addAll(headers); //Adds all headers generated by an analysis, but only for the first AOI
metrics.get(0).addAll(Arrays.asList(additionalHeaders));
}
results.get(1).add(aoiKey);
metrics.add(results.get(1));
metrics.get(row).addAll(getProportions(allFixations, aoiFixations, totalDuration));
validAOIs.put(aoiKey, aoiFixations);
row++;
// }
DataEntry singleAoiGaze = aoiMetrics.get(aoiKey);
DataEntry singleAoiFixations = aoiFixationMetrics.get(aoiKey);

singleAoiGaze.writeToCSV(outputDirectory + "/AOIs", aoiKey + "_all_gaze");

List<List<String>> results = Analysis.generateResults(allGazeData, singleAoiGaze, singleAoiFixations);
if (isFirst) { //
isFirst = false;
List<String> headers = results.get(0);
metrics.get(0).addAll(headers); //Adds all headers generated by an analysis, but only for the first AOI
metrics.get(0).addAll(Arrays.asList(additionalHeaders));
}
results.get(1).add(aoiKey);
metrics.add(results.get(1));
metrics.get(row).addAll(getProportions(allFixations, singleAoiFixations, totalDuration));
validAOIs.put(aoiKey, singleAoiFixations);
row++;
}
ArrayList<List<String>> pairResults = generatePairResults(allFixations, aoiMetrics);
/*for (int i = 0; i < pairResults.size(); i++) { //Write values to all rows
for (String s : perAoiHeaders) {
metrics.get(0).add(s + "_" + i); //Adds headersfor each pair.
}
metrics.get(i + 1).addAll(pairResults.get(i));
}*/
FileHandler.writeToCSV(metrics, outputDirectory, fileName + "_AOI_DGMs");
FileHandler.writeToCSV(pairResults, outputDirectory, fileName+"_AOI_Transitions");
}

// public static ArrayList<String> generateAreaOfInterestResults(DataEntry all,DataEntry aoi, double totalDuration) {
// DataEntry valid = DataFilter.filterByValidity(all);
// ArrayList<String> results = new ArrayList<>();
// List<String> proportions = getProportions(valid, aoi, totalDuration);
// results.addAll(proportions);
// return results;
// }

public static ArrayList<String> getProportions(DataEntry fixations, DataEntry aoiFixations, double totalDuration) {
ArrayList<String> results = new ArrayList<>();
double fixationProportion = (double)aoiFixations.rowCount()/fixations.rowCount(); //Number of fixations in AOI divided by total fixations
Expand Down
90 changes: 59 additions & 31 deletions src/main/java/com/github/thed2lab/analysis/SaccadeVelocity.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,41 +10,69 @@ public class SaccadeVelocity {
final static String FIXATIONX_INDEX = "FPOGX";
final static String FIXATIONY_INDEX = "FPOGY";
final static String FIXATION_VALIDITY_INDEX = "FPOGV";
final static String FIXATION_DURATION_INDEX = "FPOGD";

static public LinkedHashMap<String,String> analyze(DataEntry data) {
/**
* Calculates the average peak saccade velocity. Iterates over all rows of a participant’s gaze.
* If the current row’s fixation ID (“FID”) and the next consecutive fixation ID both appear in
* the fixation data as well as if the fixation validity (“FPOGV”) is set to 0, then the row is
* considered part of a saccade.
* @param allGazeData all gaze data, filtered by validity with screen size applied. When calculating
* for AOIs, use all gaze data, not just the AOI specific gaze data.
* @param fixationData the gaze data, filtered by fixation and validity with screen size applied.
* When calculating for AOIs, use only fixation data that occurs within the AOI.
* @return average peak saccade velocity’s header mapped to the calculated value as a {@code String}.
*/
static public LinkedHashMap<String,String> analyze(DataEntry allGazeData, DataEntry fixationData) {
LinkedHashMap<String,String> results = new LinkedHashMap<String,String>();

List<List<Double[]>> positionProfiles = new ArrayList<List<Double[]>>();
List<Double[]> positionProfile = new ArrayList<Double[]>();
ArrayList<Double> peakSaccadeVelocities = new ArrayList<Double>();
int fixDataIndex = 0;
int gazeDataIndex = 0;
int targetFixId = -1;

while ((fixDataIndex < fixationData.rowCount() - 1) && (gazeDataIndex < allGazeData.rowCount())) {
// Get the fixation Id of the next saccade that occurs completely within portion of screen (whole or AOI)
while (fixDataIndex < fixationData.rowCount() - 1) {
int curFixId = Integer.parseInt(fixationData.getValue(FIXATIONID_INDEX, fixDataIndex));
int nextFixId = Integer.parseInt(fixationData.getValue(FIXATIONID_INDEX, fixDataIndex + 1));
fixDataIndex++;
if (nextFixId == curFixId + 1) {
targetFixId = curFixId;
break;
}
}

while (gazeDataIndex < allGazeData.rowCount()) {
int curId = Integer.parseInt(allGazeData.getValue(FIXATIONID_INDEX, gazeDataIndex));
if (curId < targetFixId) {
gazeDataIndex++;
continue;
} else if (curId > targetFixId) {
break; // could not find target, look for next fixation
}

String prevID = "";
for (int i = 0; i < data.rowCount(); i++) {
boolean saccade = Integer.parseInt(data.getValue(FIXATION_VALIDITY_INDEX, i)) == 0 ? true : false;
if (saccade) {
// Check to see if these saccade points are part of the same saccade
String currID = data.getValue(FIXATIONID_INDEX, i);
if (!prevID.equals(currID) && positionProfile.size() != 0) {
positionProfiles.add(positionProfile);
positionProfile = new ArrayList<Double[]>();
boolean saccade = Integer.parseInt(allGazeData.getValue(FIXATION_VALIDITY_INDEX, gazeDataIndex)) == 0 ? true : false;
// Check if not a saccade
if (!saccade) {
gazeDataIndex++;
continue; // go to next data point
}

Double x = Double.parseDouble(data.getValue(FIXATIONX_INDEX, i));
Double y = Double.parseDouble(data.getValue(FIXATIONY_INDEX, i));
Double t = Double.parseDouble(data.getValue(TIME_INDEX, i));
Double x = Double.parseDouble(allGazeData.getValue(FIXATIONX_INDEX, gazeDataIndex));
Double y = Double.parseDouble(allGazeData.getValue(FIXATIONY_INDEX, gazeDataIndex));
Double t = Double.parseDouble(allGazeData.getValue(TIME_INDEX, gazeDataIndex));
positionProfile.add(new Double[] {x, y, t});
prevID = currID;

} else if (positionProfile.size() != 0) {
gazeDataIndex++;
}

if (positionProfile.size() > 0) {
positionProfiles.add(positionProfile);
positionProfile = new ArrayList<Double[]>();
}
}

// add the last saccade profile if it isn't already added
// see issue #39 and #40 for explanation
if (positionProfile.size() > 0) {
positionProfiles.add(positionProfile);

}

for (int i = 0; i < positionProfiles.size(); i++) {
Expand All @@ -61,7 +89,6 @@ static public LinkedHashMap<String,String> analyze(DataEntry data) {
return results;
}


/**
* Returns the peak velocity of a given saccade calculated using a two point central difference algorithm.
*
Expand All @@ -72,15 +99,16 @@ static public LinkedHashMap<String,String> analyze(DataEntry data) {
*
* @return The peak velocity of a saccade
*/
public static double getPeakVelocity(List<Double[]> saccadePoints) {
static double getPeakVelocity(List<Double[]> saccadePoints) {
if (saccadePoints.size() == 0 || saccadePoints.size() == 1) {
return Double.NaN;
}

double peakVelocity = 0;
double conversionRate = 0.0264583333; // Convert from pixels to cms
double velocityThreshold = 700; // Maximum possible saccadic velocity
int participantDistance = 65; // assume an average distance of 65cm from the participant to the screen
final double PIXELS_TO_CM = 0.0264583333; // Convert from pixels to cms
final double VELOCITY_THRESHOLD = 700; // Maximum possible saccadic velocity
final double PARTICIPANT_DISTANCE = 65; // assume an average distance of 65cm from the participant to the screen
final double RADIAN_TO_DEGREES = 180/Math.PI;
double peakVelocity = 0;

for (int i = 1; i < saccadePoints.size(); i++) {
Double[] currPoint = saccadePoints.get(i);
Expand All @@ -91,13 +119,13 @@ public static double getPeakVelocity(List<Double[]> saccadePoints) {
double x2 = prevPoint[0];
double y2 = prevPoint[1];

double dx = Math.sqrt(Math.pow(Math.abs(x1 - x2), 2) + Math.pow(Math.abs(y1 - y2), 2)) * conversionRate;
double dx = Math.sqrt(Math.pow(Math.abs(x1 - x2), 2) + Math.pow(Math.abs(y1 - y2), 2)) * PIXELS_TO_CM;
double dt = Math.abs(currPoint[2] - prevPoint[2]);
double amplitude = 180/Math.PI * Math.atan(dx/participantDistance);
double amplitude = RADIAN_TO_DEGREES * Math.atan(dx/PARTICIPANT_DISTANCE);

double velocity = amplitude/dt;

if (velocity > peakVelocity && velocity <= velocityThreshold) {
if (velocity > peakVelocity && velocity <= VELOCITY_THRESHOLD) {
peakVelocity = velocity;
}
}
Expand Down
Loading

0 comments on commit d2a3c68

Please sign in to comment.