Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix saccade velocity sorting #53

Merged
merged 3 commits into from
Aug 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading