Adding in the nonlinearity processing scripts

For the non-linearity compensation filter to work we need to be able to
process the many many logs that are recorded to build up the lookup
table that is later used to adjust positions as they come in.  These
scripts have been in flux and now have somewhat stabilized so I'm
committing them to the touchbot project for future use.

parse.py --> Parses a collection of activity_logs and saves "strokes" to
disk which are pickled lists of (x, y, p) tuples each corresponding to a
single continuous line drawn on the Touchpad

showPath.py --> Loads pre-parsed stroke.p files, and plots their
coordinates using matplot lib to see what the pattern of the strokes
was.  Very useful for debugging

progress --> When you're doing these with real data it can take quite a
bit of time to load the thousands of points and do all the processing.
This progress bar class is just for the ease of use of the operator

stroke --> Class representing a single continuous line on the touchpad.
It is essentially a list of (x, y, p) points with a few helper functions
to allow us to manipulate the strokes better

General usage:
You'll have a whole bunch of activity_log1.txt, activity_log2.txt, ...

    python parse.py activity_log*.txt

This generates stroke1.p, stroke2.p, ... stroke2134.p

    python showPath.py stroke*.p

This loads all the stroke.p files and displays them to the screen so you
can visualize the logs.

At this point, you know you have legit stroke*.p files and can use other
scripts to anylize them further and will be added later, but for the
sake of incrementally merging these in, I'm just starting with the basic
functionality

BUG=chromium:233579
TEST=manual testing as described in the commit msg using data collected
off a real system

Change-Id: I857781ed03af645412728651bbe27218fb051aba
Signed-off-by: Charlie Mooney <charliemooney@chromium.org>
Reviewed-on: https://gerrit.chromium.org/gerrit/57517
Reviewed-by: Joseph Shyh-In Hwang <josephsih@chromium.org>
diff --git a/tools/nonlinearity-tools/lib/__init__.py b/tools/nonlinearity-tools/lib/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tools/nonlinearity-tools/lib/__init__.py
diff --git a/tools/nonlinearity-tools/lib/progress.py b/tools/nonlinearity-tools/lib/progress.py
new file mode 100755
index 0000000..1d64506
--- /dev/null
+++ b/tools/nonlinearity-tools/lib/progress.py
@@ -0,0 +1,79 @@
+# Copyright (c) 2013 The Chromium OS Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import sys
+import time
+
+
+class ProgressBar:
+    """ A simple progress indicator class
+
+    When you initialize a new ProgressBar, you give it the number of tasks you
+    are going to do.  Then each time you complete one (or some) you use the
+    progress() member function to update the display.
+
+    If you want to further divide one of the tasks, you can use subtask() to
+    subdivide the progress of the task currently being worked on.
+
+    Example:
+        bar = ProgressBar(len(work))
+        for w in work:
+            # Process w here
+            bar.progress()
+    """
+    BAR_WIDTH = 50
+
+    def __init__(self, num_items):
+        self._start_time = time.time()
+        self.num_items = []
+        self.num_done = []
+        self.subtask(num_items)
+        self._print_progress()
+
+    def progress(self, amount=1):
+        self.num_done[-1] += amount
+        self._print_progress()
+
+        if self.num_done[-1] >= self.num_items[-1]:
+            self.num_done.pop()
+            self.num_items.pop()
+
+            if not self.num_items:
+                print "\n"
+
+    def subtask(self, num_items):
+        self.num_items.append(float(num_items))
+        self.num_done.append(0)
+
+    def _calculate_progress(self):
+        perc = 0.0
+        value = 1.0
+
+        for i in range(len(self.num_done)):
+            perc += (self.num_done[i] / self.num_items[i]) * value
+            value *= (1 / self.num_items[i])
+        return perc
+
+    def _duration(self):
+        time_taken = time.time() - self._start_time
+        seconds = time_taken % 60
+        minutes = int(time_taken / 60) % 60
+        s = "%02d:%02d" % (minutes, seconds)
+
+        hours = int(time_taken / (60 * 60))
+        if hours > 0:
+            s = "%02d:%s" % (hours, s)
+        return s
+
+    def _print_progress(self):
+        percent = self._calculate_progress()
+        if percent > 1.0: percent = 1.0
+
+        sys.stdout.write("Progress [")
+        for i in range(self.BAR_WIDTH):
+            if i <= percent * self.BAR_WIDTH:
+                sys.stdout.write("=")
+            else:
+                sys.stdout.write(" ")
+        sys.stdout.write("] ~%.1f%% %s\r" % (percent * 100, self._duration()))
diff --git a/tools/nonlinearity-tools/lib/stroke.py b/tools/nonlinearity-tools/lib/stroke.py
new file mode 100755
index 0000000..cc0e2cb
--- /dev/null
+++ b/tools/nonlinearity-tools/lib/stroke.py
@@ -0,0 +1,98 @@
+# Copyright (c) 2013 The Chromium OS Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+from itertools import izip
+import pickle
+
+import numpy as np
+
+
+class Stroke:
+    """ A class to hold all the position data for a single continuous motion
+
+    A 'Stroke' is essentially a list of (t, x, y, p) tuples storing:
+        * Time-stamp
+        * X & Y positions
+        * Pressure value
+    but with some helper functions allowing you to easily do file i/o, line
+    fitting, and stroke validation for non-linearity analysis.
+    """
+    MIN_STROKE_LENGTH = 10
+    MIN_STROKE_TIME = 0.5
+    MAX_FIT_DEVIATION = 85
+    MAX_AVG_FIT_DEVIATION = 78
+    MAX_SLOPE_DEVIATION = 0.15
+
+    def __init__(self, events=[]):
+        self.events = events
+
+    def __iter__(self):
+        for event in self.events:
+            yield event
+
+    @classmethod
+    def load_from_file(cls, filename):
+        """ Load events from a file and create a new Stroke """
+        return cls(pickle.load(open(filename, 'rb')))
+
+    def save_to_file(self, filename):
+        """ Store the stroke to disk as a pickled list to be read later """
+        pickle.dump(self.events, open(filename, "wb"))
+
+    def compute_error(self):
+        """ Compute the error at each point for a stroke """
+        ideal = self.find_ideal()
+        error = [ ((sx, sy, sp), (sx - ix, sy - iy))
+                  for (it, ix, iy, ip), (st, sx, sy, sp)
+                  in izip(ideal, self.events) ]
+        return dict(error)
+
+    def find_ideal(self, degree=1):
+        """ Fit a nice smooth polynomial to the events in this stroke"""
+        poly_x = np.polyfit([t for t, x, y, p in self.events],
+                            [x for t, x, y, p in self.events], degree)
+        poly_y = np.polyfit([t for t, x, y, p in self.events],
+                            [y for t, x, y, p in self.events], degree)
+        return [(t, np.polyval(poly_x, t), np.polyval(poly_y, t), p)
+                for t, x, y, p in self.events]
+
+    def clip(self, clip_amount=30):
+        """ Clip both ends and returns the number of remaining events """
+        if len(self.events) > 2 * clip_amount:
+            self.events = self.events[clip_amount:-clip_amount]
+        else:
+            self.events = []
+        return len(self.events)
+
+    def is_useful(self):
+        """ Checks several things about the stroke to confirm that is is
+        suitable for use in non-linearity filter computation.
+        """
+        if not self.events:
+            return False
+        if len(self.events) < self.MIN_STROKE_LENGTH:
+            return False
+        if self.events[-1][0] - self.events[0][0] < self.MIN_STROKE_TIME:
+            return False
+
+        ideal = self.find_ideal()
+        deviations = [self._distance(s, i) for s, i in izip(self.events, ideal)]
+        if max(deviations) > self.MAX_FIT_DEVIATION:
+            return False
+        avg_deviation = sum(deviations) / len(deviations)
+        if avg_deviation > self.MAX_AVG_FIT_DEVIATION:
+            return False
+
+        if ideal[-1][1] == ideal[0][1]:
+            return False
+        slope = (ideal[-1][2] - ideal[0][2]) / (ideal[-1][1] - ideal[0][1])
+        if abs(abs(slope) - 1.0) > self.MAX_SLOPE_DEVIATION:
+            return False
+
+        return True
+
+    def _distance(self, e1, e2):
+        t1, x1, y1, p1 = e1
+        t2, x2, y2, p2 = e2
+        return ((x1 - x2) ** 2 + (y1 - y2) ** 2) ** 0.5
diff --git a/tools/nonlinearity-tools/parse.py b/tools/nonlinearity-tools/parse.py
new file mode 100755
index 0000000..b546ba8
--- /dev/null
+++ b/tools/nonlinearity-tools/parse.py
@@ -0,0 +1,83 @@
+# Copyright (c) 2013 The Chromium OS Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+""" Parse activity logs into individual pickled strokes
+
+Given a series of activity logs from the touchpad, this parses and dedupes the
+events, then separates them into individual strokes across the pad.  Each of
+these strokes is then saved to disk in it's own stroke##.p file for further
+processing.
+
+Usage: python parse.py linearity_logs/*.txt
+"""
+
+import json
+import sys
+
+from lib.progress import ProgressBar
+from lib.stroke import Stroke
+
+
+logs = sys.argv[1:]
+
+# First get all of the hardwareState events and store them all in one big list
+print "Parsing the logs..."
+bar = ProgressBar(len(logs))
+data = []
+for log in logs:
+    # Open up each log and parse the json
+    log_data = json.load(open(log, 'r'))
+
+    # We are only interested in the hardwareState entries
+    hwstates = [e for e in log_data.get('entries')
+                if e.get('type') == 'hardwareState']
+
+    if hwstates:
+        bar.subtask(len(hwstates))
+        for e in hwstates:
+            fingers = e.get('fingers')
+            if len(fingers) == 0:
+                # Add dummy events to indicate the end of a stroke
+                data.append((e.get('timestamp'), None, None, None))
+            elif len(fingers) == 1:
+                # Otherwise, add a new event onto the stroke
+                t = e.get('timestamp')
+                x = fingers[0].get('positionX')
+                y = fingers[0].get('positionY')
+                p = fingers[0].get('pressure')
+                data.append((t, x, y, p))
+            bar.progress()
+    bar.progress()
+
+# Sort and dedupe the list of events
+data = sorted(data, key=lambda tup: tup[0])
+events = []
+for i, e in enumerate(data[0:-2]):
+    timestamp, next_timestamp = e[0], data[i + 1][0]
+    consecutive_empty_values = e[1] is None and data[i + 1][1] is None
+    if timestamp != next_timestamp and not consecutive_empty_values:
+        events.append(e)
+
+# Split the list of events into individual Strokes wherever the finger lifted
+strokes = []
+current_stroke = []
+for e in events:
+    if e[1] == None:
+        strokes.append(Stroke(current_stroke))
+        current_stroke = []
+    else:
+        current_stroke.append(e)
+if current_stroke:
+    strokes.append(Stroke(current_stroke))
+
+# Write the "good" (straight and long) strokes to disk
+print "Writing the strokes to disk..."
+bar = ProgressBar(len(strokes))
+for i, stroke in enumerate(strokes):
+    stroke.clip()
+    if stroke.is_useful():
+        stroke.save_to_file('stroke%d.p' % i)
+    bar.progress()
+
+print "Done.  The results are stored in stroke###.p"
diff --git a/tools/nonlinearity-tools/showPath.py b/tools/nonlinearity-tools/showPath.py
new file mode 100755
index 0000000..2c96c32
--- /dev/null
+++ b/tools/nonlinearity-tools/showPath.py
@@ -0,0 +1,32 @@
+# Copyright (c) 2013 The Chromium OS Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import sys
+
+import matplotlib.pyplot as plt
+
+from lib.progress import ProgressBar
+from lib.stroke import Stroke
+
+
+strokes = [Stroke.load_from_file(arg) for arg in sys.argv[1:]]
+
+print "Adding %d strokes to figure..." % len(strokes)
+bar = ProgressBar(len(strokes))
+
+for stroke in strokes:
+    s = [(t, x, y, p) for t, x, y, p in stroke]
+    i = [(t, x, y, p) for t, x, y, p in stroke.find_ideal()]
+    e = stroke.compute_error()
+
+    plt.plot([x for t, x, y, p in stroke], [y for t, x, y, p in stroke])
+    plt.plot([x for t, x, y, p in stroke.find_ideal()],
+             [y for t, x, y, p in stroke.find_ideal()])
+
+    plt.plot([x for t, x, y, p in s], [e[(x, y, p)][1] for t, x, y, p in s])
+    plt.plot([e[(x, y, p)][0] for t, x, y, p in s], [y for t, x, y, p in s])
+    bar.progress()
+
+print "Done, displaying the plot now."
+plt.show()