blob: 3494012e26262af3408fa3fa730451211e78da20 [file] [log] [blame]
# Copyright (c) 2012 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.
"""Validators to verify if events conform to specified criteria."""
import math
import numpy as np
import sys
import fuzzy
from inspect import isfunction
from result import Result
from minicircle import minicircle
class BaseValidator(object):
""" Base class for validators.
This class defines the basic interface and functionality for a Validator.
Any actual Validators should be have BaseValidator as a superclass and will
be used with the following workflow:
# TODO(charliemooney): Fill this in
"""
_device = None
def __init__(self, criteria, mf=None, name=None,
description='No description given.'):
self.criteria_str = criteria() if isfunction(criteria) else criteria
self.fc = fuzzy.FuzzyCriteria(self.criteria_str, mf=mf)
self.name = name
self.description = description
def _Distance(self, p1, p2):
return math.sqrt((p1.x - p2.x) ** 2 + (p1.y - p2.y) ** 2)
def _DistanceMm(self, p1, p2):
p1_mm_x, p1_mm_y = BaseValidator._device.PxToMm((p1.x, p1.y))
p2_mm_x, p2_mm_y = BaseValidator._device.PxToMm((p2.x, p2.y))
return math.sqrt((p1_mm_x - p2_mm_x) ** 2 + (p1_mm_y - p2_mm_y) ** 2)
def _SegmentPath(self, path, start_and_end_percentage=0.1):
""" Assuming that the path takes a roughly straight line, this function
segments the fingers that are in the extreme ends of the line (by
distance, not time or position in the array) It then returns the three
sectioned off segments (start, middle, end)
"""
start = path[0]
end = path[-1]
total_distance = self._Distance(start, end)
trim_distance = start_and_end_percentage * total_distance
lo = hi = None
for i, finger in enumerate(path):
# If we haven't found the first finger yet, check if it's far enough away
# from the start to not get trimmed.
if lo is None:
if self._Distance(finger, start) > trim_distance:
lo = i
# Otherwise see if this finger is too close to the end
elif hi is None:
if self._Distance(finger, end) < trim_distance:
hi = i
return path[:lo], path[lo:hi], path[hi:]
def _SeparatePaths(self, snapshots):
""" Given all the snapshots of some gesture, separate the fingers into
paths of contiguous events. This takes in a list of MtbSnapshots and
outputs list of "paths" which are themselves a list of MtFingers (in order).
Note: Tracking IDs can be re-used if there is a gap between the events
so it is not valid to simply separate events based on TID. You have to
check the order of events.
"""
paths = []
paths_in_progress = {}
for snapshot in snapshots:
for finger in snapshot.fingers:
# Append this finger onto the right path and start a new path if needed
if finger.tid not in paths_in_progress:
paths_in_progress[finger.tid] = []
paths_in_progress[finger.tid].append(finger)
# Remove any terminated paths from the in-progress dicttionary
tids = set([(finger.tid) for finger in snapshot.fingers])
for tid, path in paths_in_progress.items():
if tid not in tids:
paths.append(path)
del paths_in_progress[tid]
# Make sure that the last finger(s) to leave the pad get counted, too
for path in paths_in_progress.values():
paths.append(path)
return paths
def _PathOfNthFinger(self, finger_num, paths):
""" Return the path of the nth finger to touch the pad """
if len(paths) <= finger_num:
return []
ordered_paths = sorted(paths, key=lambda path:path[0].syn_time)
return [ordered_paths[finger_num]]
def _MinimumEnclosingRadius(self, fingers):
""" Compute the radius (in mm) of the smallest circle that can surround all
the points where the fingers are.
"""
if len(fingers) <= 1:
return 0
fingers_mm = [BaseValidator._device.PxToMm((f.x, f.y)) for f in fingers]
circle = minicircle(fingers_mm)
return circle.radius
def Validate(self, snapshots):
raise NotImplementedError('Not implemented in subclass.')
class FingerCountValidator(BaseValidator):
""" Validator to check the number of fingers observed.
Example:
To verify if there is exactly one finger observed:
FingerCountValidator('== 1')
"""
def __init__(self, criteria_str, mf=None):
name = self.__class__.__name__
desc = ("How many fingers did the touch device report to the kernel? "
"This validator checks the number of fingers seen throughout "
"the course of the entire gesture. This is determined by counting "
"the number of tracking IDs that have been seen, and is used to "
"make sure there are no stray touches, split/merged fingers, or "
"missed touches.")
super(FingerCountValidator, self).__init__(criteria_str, mf, name, desc)
def Validate(self, snapshots):
paths = self._SeparatePaths(snapshots)
observed_fingers = len(paths)
result = Result()
result.name = self.name
result.description = self.description
result.units = '#'
result.criteria = self.criteria_str
result.observed = observed_fingers
result.score = self.fc.mf.grade(result.observed)
return result
class ReportRateValidator(BaseValidator):
""" Validator to check the report rate.
Example:
To verify that the report rate is around 80 Hz. It gets 0 points
if the report rate drops below 60 Hz.
ReportRateValidator('== 80 ~ -20')
"""
MIN_MOVING_DISTANCE_PX = 4
def __init__(self, criteria_str, mf=None):
name = self.__class__.__name__
desc = ("At what rate were positions reported during the course of the "
"gesture? This validator checks the number of reports per "
"second that are generated by the driver. During periods "
"without any movement, delta-compression in the kernel may cause "
"incorrectly low report rates to be observed, so this validator "
"should only be applied to gestures where the fingers are moving.")
super(ReportRateValidator, self).__init__(criteria_str, mf, name, desc)
def Validate(self, snapshots):
""" Check that during any period on movement each finger has a high
enough report rate.
"""
# First separate out each finger by itself.
paths = self._SeparatePaths(snapshots)
# If a finger isn't moving, its report rate may drop due to the MTB delta
# compression. Any time the finger's x/y isn't changing much between two
# fingers, we should ignore that time delta.
moving_segments = []
for path in paths:
start = 0
for i, finger in enumerate(path):
if i == 0:
continue
distance_moved = self._Distance(finger, path[i - 1])
if distance_moved > ReportRateValidator.MIN_MOVING_DISTANCE_PX:
continue
if start != i - 1:
moving_segments.append(path[start:i])
start = i
moving_segments.append(path[start:])
# Compute the time deltas between consecutive readings for each finger
deltas = []
for segment in moving_segments:
times = [finger.syn_time for finger in segment]
deltas.extend([times[i] - times[i - 1] for i in range(1, len(times))])
# The average time delta between events during moving periods can be used
# to calculate an average report rate
observed_report_rate = -1
if deltas:
avg_delta = sum(deltas) / len(deltas)
observed_report_rate = 1.0 / avg_delta
# Finally package up the result and return it for the closest edge
result = Result()
result.name = self.name
result.description = self.description
result.units = 'Hz'
result.criteria = self.criteria_str
result.observed = observed_report_rate
result.score = self.fc.mf.grade(result.observed)
if result.observed == -1:
result.error = ('No usable events were collected. ' +
'Make sure the finger is moving during the test.')
return result
class RangeValidator(BaseValidator):
""" A Validator to check the range of observed (x, y) positions.
This Validator checks the ranges of the x and y positions reported and
confirms that they all fall within the min/max values we expect.
Example:
To check the range of observed edge-to-edge positions:
RangeValidator('<= 0.05, ~ +0.05')
"""
def __init__(self, criteria_str, mf=None):
name = self.__class__.__name__
desc = ("How close to the %s of the touch sensor was a finger detected? "
"This validator measures the minimum distance to the edge of the "
"sensor throughout the gesture and checks that you are able to "
"use the device all the way up to the edges.")
super(RangeValidator, self).__init__(criteria_str, mf, name, desc)
def Validate(self, snapshots):
""" Check to see the range of X/Y values reported and make sure they're
within what we expect for this device.
"""
# Find the extreme X/Y values that have been reported
min_x = min_y = float('inf')
max_x = max_y = float('-inf')
for snapshot in snapshots:
for finger in snapshot.fingers:
min_x = min(min_x, finger.x)
max_x = max(max_x, finger.x)
min_y = min(min_y, finger.y)
max_y = max(max_y, finger.y)
# Convert those values to mm
max_x_mm = BaseValidator._device.PxToMm_X(max_x)
min_x_mm = BaseValidator._device.PxToMm_X(min_x)
max_y_mm = BaseValidator._device.PxToMm_Y(max_y)
min_y_mm = BaseValidator._device.PxToMm_Y(min_y)
# Also convert the touch sensors dimensions to mm
dev_min_x, dev_max_x = BaseValidator._device.RangeX()
dev_min_y, dev_max_y = BaseValidator._device.RangeY()
dev_min_x_mm = BaseValidator._device.PxToMm_X(dev_min_x)
dev_max_x_mm = BaseValidator._device.PxToMm_X(dev_max_x)
dev_min_y_mm = BaseValidator._device.PxToMm_Y(dev_min_y)
dev_max_y_mm = BaseValidator._device.PxToMm_Y(dev_max_y)
# We should be able to guess the direction based on which edge was close
right_gap = dev_max_x_mm - max_x_mm
left_gap = min_x_mm - dev_min_x_mm
bottom_gap = dev_max_y_mm - max_y_mm
top_gap = min_y_mm - dev_min_y_mm
min_gap = float('inf')
detected_direction = None
for gap, direction in [(left_gap, 'Left'), (top_gap, 'Top'),
(right_gap, 'Right'), (bottom_gap, 'Bottom')]:
if abs(gap) < abs(min_gap):
min_gap = gap
detected_direction = direction
# Finally package up the result and return it for the closest edge
result = Result()
result.units = 'mm'
result.criteria = self.criteria_str
result.observed = min_gap
result.score = self.fc.mf.grade(result.observed)
if detected_direction:
result.name = self.name + detected_direction
result.description = self.description % detected_direction.lower()
else:
result.name = self.name
result.description = self.description % 'UNKNOWN'
result.error = ('Unable to detect which edge was being tested. ' +
'Probably not enough events were collected.')
return result
class NoGapValidator(BaseValidator):
""" Validator to make sure that there are no significant gaps in a line.
Example:
To verify that no gap is more than 5x the size of the previous one
NoGapValidator('<= 5, ~ +5')
"""
def __init__(self, criteria_str, mf=None):
name = self.__class__.__name__
desc = ("What is the biggest ratio of the gaps between consecutive "
"reported positions? This validator measures the Euclidean "
"distance between each (x, y) coordinate for a given tracking "
"ID, and then computes the ratio of those distances and their "
"neighbors. The idea is that a nice, smooth line should have "
"roughly equal gaps between consecutive reports, assuming that "
"the finger was moving at a roughly constant speed. If there "
"are dropped frames, smoothing issues, or inconsistent scans "
"then some gaps may be much larger than others, causing jitter "
"for pointer movement and scrolling.")
super(NoGapValidator, self).__init__(criteria_str, mf, name, desc)
def Validate(self, snapshots):
""" Compute the ratio of the distances between events and grade that
This function tries to find the largest gap ratio between two gaps
with the restriction that next gap is somewhat smaller. The ratio threshold
is used to prevent the gaps detected in a swipe. In a swipe, the gaps tend
to become larger and larger. If the next gap is smaller we can assume
that this wasn't an accelerating finger, but rather that there was a
glitch in the reports.
"""
RATIO_THRESHOLD_CURR_GAP_TO_NEXT_GAP = 1.2
GAP_LOWER_BOUND = 10
paths = self._SeparatePaths(snapshots)
largest_gap_ratio = float('-inf')
for path in paths:
gaps = [self._Distance(path[i], path[i + 1])
for i in range(len(path) - 1)]
for i in range(1, len(gaps) - 1):
prev_gap = max(gaps[i - 1], 1)
curr_gap = gaps[i]
next_gap = max(gaps[i + 1], 1)
gap_ratio_with_prev = curr_gap / prev_gap
gap_ratio_with_next = curr_gap / next_gap
if (curr_gap >= GAP_LOWER_BOUND and
gap_ratio_with_next > RATIO_THRESHOLD_CURR_GAP_TO_NEXT_GAP):
largest_gap_ratio = max(largest_gap_ratio, gap_ratio_with_prev)
else:
largest_gap_ratio = max(largest_gap_ratio, 1.0)
result = Result()
result.name = self.name
result.description = self.description
result.units = '*ratio*'
result.criteria = self.criteria_str
# If there were not enough gaps and we know nothing report an error
if largest_gap_ratio < 0:
largest_gap_ratio = float('inf')
result.error = ('Error computing gap ratios. Perhaps there were not '
'enough events collected.')
result.observed = largest_gap_ratio
result.score = self.fc.mf.grade(result.observed)
return result
class LinearityValidator(BaseValidator):
""" Validator to verify linearity based on x vs y
Example:
To check the linearity of the line drawn by the first finger:
LinearityValidator('<= 0.03, ~ +0.07', finger=0)
"""
# Define the partial group size for calculating Mean Squared Error
MSE_PARTIAL_GROUP_SIZE = 1
def __init__(self, criteria_str, mf=None, finger=None):
name = self.__class__.__name__
name += ('Finger%d' % finger) if finger is not None else 'AllFingers'
desc = ("How straight is the line? This validator measures how much "
"the line drawn in the gesture deviates from perfectly straight. "
"This is done by performing a linear regression in X vs. Y, then "
"computing the distance from this ideal line for each point. "
"The maximum deviation, serves as the observed value. Note: "
"this validator may be run on any one or all of the fingers in "
"the gesture. If a finger number is indicated in the name, that "
"corresponds to the order they touched the pad, otherwise all "
"fingers are checked.")
self.finger = finger
super(LinearityValidator, self).__init__(criteria_str, mf, name, desc)
def _CalculateResiduals(self, line, times, values):
""" Calculate the residuals for this time/value series against the line.
@param line: The regression line of values vs time
@param times: a list of times
@param values: a list of the values corresponding to those times
This method returns the list of residuals, where
residual[i] = line[t_i] - v_i
where t_i is an element in times and
v_i is a corresponding element in values.
We calculate the vertical distance (value distance) here because the
horizontal axis (times) always represent the time instants, and the
vertical axis (values) should be either the coordinates in x or y axis.
"""
return [float(line(t) - v) for t, v in zip(times, values)]
def _SimpleLinearRegression(self, times, values):
"""Calculate the simple linear regression line and returns the
sum of squared residuals.
@param times: the list of time instants
@param values: the list of corresponding x or y coordinates
It calculates the residuals (fitting errors) of the points at the
specified segments against the computed simple linear regression line.
Reference:
- Simple linear regression:
http://en.wikipedia.org/wiki/Simple_linear_regression
- numpy.polyfit(): used to calculate the simple linear regression line.
http://docs.scipy.org/doc/numpy/reference/generated/numpy.polyfit.html
"""
# At least 2 points to determine a line.
if len(times) < 2 or len(values) < 2:
return []
midsection_start = int(len(times) * 0.1)
midsection_end = len(times) - midsection_start
mid_segment_t = times[midsection_start:midsection_end]
mid_segment_y = values[midsection_start:midsection_end]
# Check to make sure there are enough samples to continue
if len(mid_segment_t) <= 2 or len(mid_segment_y) <= 2:
return []
# Calculate the simple linear regression line.
degree = 1
regress_line = np.poly1d(np.polyfit(mid_segment_t, mid_segment_y, degree))
# Compute the fitting errors of the specified segments.
return self._CalculateResiduals(regress_line, mid_segment_t, mid_segment_y)
def _ErrorsForSingleAxis(self, times, values):
""" Calculate linearity errors for one set of data points vs time """
# It is fine if axis-time is a horizontal line.
errors_px = self._SimpleLinearRegression(times, values)
if not errors_px:
return (0, 0)
# Calculate the max errors
max_err_px = max(map(abs, errors_px))
# Calculate the root mean square errors
e2 = [e * e for e in errors_px]
rms_err_px = (float(sum(e2)) / len(e2)) ** 0.5
return (max_err_px, rms_err_px)
def Validate(self, snapshots):
""" Check if the fingers conform to specified criteria. """
paths = self._SeparatePaths(snapshots)
if self.finger is not None:
paths = self._PathOfNthFinger(self.finger, paths)
global_max_err_mm = float('-inf')
for path in paths:
start, middle, end = self._SegmentPath(path)
path = middle
xs_mm = [BaseValidator._device.PxToMm_X(finger.x) for finger in path]
ys_mm = [BaseValidator._device.PxToMm_Y(finger.y) for finger in path]
times = [finger.syn_time for finger in path]
# Linear fitting becomes numerically unstable when lines approach
# vertical. To combat this, we fit in both x vs y as well was y vs x
# and then select whichever fits better (less error) as the correct
# fit.
max_xy_err_mm, rms_xy_err_mm = self._ErrorsForSingleAxis(xs_mm, ys_mm)
max_yx_err_mm, rms_yx_err_mm = self._ErrorsForSingleAxis(ys_mm, xs_mm)
err_mm = min(max_xy_err_mm, max_yx_err_mm)
global_max_err_mm = max(global_max_err_mm, err_mm)
result = Result()
result.name = self.name
result.description = self.description
result.units = 'mm'
result.criteria = self.criteria_str
if global_max_err_mm < 0:
global_max_err_mm = float('inf')
if not paths and self.finger is not None:
result.error = ('There was no finger #%d as far as we can tell.' %
self.finger)
else:
result.error = ('Unable to compute linearity. Perhaps there were '
'not enough events collected.')
result.observed = global_max_err_mm
result.score = self.fc.mf.grade(result.observed)
return result
class StationaryValidator(BaseValidator):
""" Validator to make sure a finger we expect to remain still didn't move.
This class is inherited by both StationaryFingerValidator and
StationaryTapValidator, and is not used directly as a validator.
"""
def __init__(self, criteria, mf=None, finger=None):
name = self.__class__.__name__
desc = ("How stationary was this finger? This validator measures the "
"maximum distance between any two (x, y) coordinates reported for "
"a given finger and makes sure there wasn't too much movement. "
"This is used when a finger is meant to be completely stationary "
"and we want to make sure it hasn't moved around, such as during a "
"click or a tap, but in other situations as well.")
super(StationaryValidator, self).__init__(criteria, mf, name, desc)
self.finger = finger
def Validate(self, snapshots):
""" Check the moving distance of the specified finger. """
paths = self._SeparatePaths(snapshots)
if self.finger is not None:
paths = self._PathOfNthFinger(self.finger, paths)
max_distance = float('-inf')
for path in paths:
for p1 in path:
for p2 in path:
max_distance = max(max_distance, self._DistanceMm(p1, p2))
result = Result()
result.name = self.name
result.description = self.description
result.units = 'mm'
result.criteria = self.criteria_str
if max_distance < 0:
max_distance = float('inf')
if not paths:
if self.finger is None:
result.error = ('There were not enough events recorded.')
else:
result.error = ('There was no finger #%d as far as we can tell.' %
self.finger)
else:
result.error = ('Unable to compute distances. Perhaps there were '
'not enough events collected.')
result.observed = max_distance
result.score = self.fc.mf.grade(result.observed)
return result
class StationaryFingerValidator(StationaryValidator):
""" Validator to check for "pulling" effects by another finger.
Example:
To verify if the specified stationary finger is not pulled away more
than 1.0 mm by another finger.
StationaryFingerValidator('<= 1.0')
"""
pass
class StationaryTapValidator(StationaryValidator):
""" Validator to check the wobble of taps and clicks.
Example:
To verify if the first tapping finger specified does not wobble larger
than 1.0 mm.
StationaryTapValidator('<= 1.0', finger=0)
"""
pass
class NoReversedMotionValidator(BaseValidator):
""" Validator to measure any reversed motion detected
Example:
To verify that there is no more than 5mm of reversed motion for the first
finger on the pad:
NoReversedMotionValidator('<= 5', finger=0)
"""
# We will want to check different portions of the line for reversed motions.
# These values indication which section of the line we want to check
MIDDLE = 'middle'
ENDS = 'ends'
RATIO_CUTOFF = 3.0
def __init__(self, criteria_str, mf=None, finger=None, section=None):
name = self.__class__.__name__
desc = ("Does the finger ever get reported going backwards relative to "
"the overall direction of motion? This validator first determines "
"which general direction the finger is moving, and then looks for "
"any reports that are going against the grain. The total distance "
"of these reversed motions are totalled. Assuming the finger is "
"moving at a constant speed and never changing directions, any "
"reversed motion is an error, and should be avoided. This "
"validator may be run on just the middle or the ends of the "
"gesture. Naturally, the middle is expected to be more stable "
"than the ends, which may have errors due to the finger "
"arriving or leaving.")
super(NoReversedMotionValidator, self).__init__(criteria_str, mf, name,
desc)
self.finger = finger
self.section = section
def _GetReversedGaps(self, path):
""" Measure any reversed motion (opposed to the general direction) """
# Measure the x/y gaps between finger readings
dxs = [path[i + 1].x - path[i].x for i in range(len(path) - 1)]
dys = [path[i + 1].y - path[i].y for i in range(len(path) - 1)]
# Measure the gaps in all directions
left_gaps = [gap for gap in dxs if gap < 0]
right_gaps = [gap for gap in dxs if gap > 0]
if len(right_gaps) > 0:
left_to_right_ratio = float(len(left_gaps)) / float(len(right_gaps))
else:
left_to_right_ratio = float('inf')
x_moving = (left_to_right_ratio > self.RATIO_CUTOFF or
left_to_right_ratio < (1.0 / self.RATIO_CUTOFF))
up_gaps = [gap for gap in dys if gap < 0]
down_gaps = [gap for gap in dys if gap > 0]
if len(down_gaps) > 0:
up_to_down_ratio = float(len(up_gaps)) / float(len(down_gaps))
else:
up_to_down_ratio = float('inf')
y_moving = (up_to_down_ratio > self.RATIO_CUTOFF or
up_to_down_ratio < (1.0 / self.RATIO_CUTOFF))
# Check which directions the line is drawing in.
reversed_gaps = []
if x_moving:
x_reversed_gaps = left_gaps if left_to_right_ratio < 1.0 else right_gaps
x_reversed_gaps_mm = [BaseValidator._device.PxToMm_X(gap)
for gap in x_reversed_gaps]
reversed_gaps.extend(x_reversed_gaps_mm)
if y_moving:
y_reversed_gaps = up_gaps if up_to_down_ratio < 1.0 else down_gaps
y_reversed_gaps_mm = [BaseValidator._device.PxToMm_Y(gap)
for gap in y_reversed_gaps]
reversed_gaps.extend(y_reversed_gaps_mm)
return reversed_gaps
def Validate(self, snapshots):
""" All X/Y values should be monotonic in the selected section of line. """
# First, find the paths for each of the fingers we are supposed to check
paths = self._SeparatePaths(snapshots)
if self.finger is not None:
paths = self._PathOfNthFinger(self.finger, paths)
# For each finger, compute the total reversed distance and sum them
reversed_total = 0
for path in paths:
sections_to_process = [path]
if self.section:
start, middle, end = self._SegmentPath(path)
if self.section == self.MIDDLE:
sections_to_process = [middle]
elif self.section == self.ENDS:
sections_to_process = [start, end]
for section_to_process in sections_to_process:
reversed_gaps = self._GetReversedGaps(section_to_process)
reversed_total += sum([abs(gap) for gap in reversed_gaps])
# Build a result object and return the results of the validator
result = Result()
result.name = self.name
result.description = self.description
result.units = 'mm'
result.criteria = self.criteria_str
if not paths:
reversed_total = float('inf')
if len(snapshots) == 0 or not self.finger:
result.error = 'No events were collected, unable to validate'
else:
result.error = ('There was no finger #%d as far as we can tell.' %
self.finger)
result.observed = reversed_total
result.score = self.fc.mf.grade(result.observed)
return result
class NoReversedMotionMiddleValidator(NoReversedMotionValidator):
def __init__(self, criteria_str, mf=None, finger=None):
super(NoReversedMotionMiddleValidator, self).__init__(
criteria_str, mf, finger, self.MIDDLE)
class NoReversedMotionEndsValidator(NoReversedMotionValidator):
def __init__(self, criteria_str, mf=None, finger=None):
super(NoReversedMotionEndsValidator, self).__init__(
criteria_str, mf, finger, self.ENDS)
class CountPacketsValidator(BaseValidator):
""" Validator to check that there is a sufficient number of readings for
each finger that is seen.
Example:
To verify if there are at least 3 readings for each finger:
CountPacketsValidator('>= 3, ~ -3')
"""
def __init__(self, criteria_str, mf=None):
name = self.__class__.__name__
desc = ("How many complete packets were seen? This validator is simply a "
"sanity check to make sure a minimum number of complete packets "
"were reported by the driver. This is used to make sure that "
"there is enough information for our gestures library during quick "
"gestures like swipes, and is essentially computed by counting the "
"number of SYN events.")
super(CountPacketsValidator, self).__init__(criteria_str, mf, name, desc)
def Validate(self, snapshots):
""" Check the number of readings for each finger """
# Find how many readings arrived for each tracking ID
paths = self._SeparatePaths(snapshots)
reading_counts = [len(path) for path in paths]
# Build a result object and return the results of the validator
# Use whichever finger had the *least* readings as the result
result = Result()
result.name = self.name
result.description = self.description
result.units = '#'
result.criteria = self.criteria_str
if not reading_counts:
result.observed = float(0)
result.score = self.fc.mf.grade(result.observed)
result.error = 'No events were collected for ANY fingers.'
else:
result.observed = min(reading_counts)
result.score = self.fc.mf.grade(result.observed)
return result
class PinchValidator(BaseValidator):
""" Validator to check that a pinch zoomed in/out.
Example:
To verify that the two fingers' relative distances changes by at least 15mm
PinchValidator('>= 15, ~ -5')
"""
def __init__(self, criteria_str, mf=None, device=None):
name = self.__class__.__name__
desc = ("Do the two fingers change their distance between each other "
"enough? This validator is meant to be a simple sanity check to "
"confirm that when performing a pinch gesture the two fingers' "
"initial distances are sufficiently different than their final "
"distances.")
super(PinchValidator, self).__init__(criteria_str, mf, name, desc)
def Validate(self, snapshots):
""" Check the relative direction of two fingers """
paths = self._SeparatePaths(snapshots)
if len(paths) > 2:
# If more than 2 fingers are observed, use the two with the most readings.
# It's likely that the extras are stray touches
paths = sorted(paths, key=lambda x:len(x))[-2:]
relative_motion = 0
if len(paths) == 2:
starting_distance = self._DistanceMm(paths[0][0], paths[1][0])
ending_distance = self._DistanceMm(paths[0][-1], paths[1][-1])
relative_motion = ending_distance - starting_distance
result = Result()
result.name = self.name
result.description = self.description
result.units = 'mm'
result.criteria = self.criteria_str
result.observed = abs(relative_motion)
result.score = self.fc.mf.grade(result.observed)
if len(paths) < 2:
result.error = 'Not enough fingers seen. Must have 2 for a pinch/zoom.'
elif len(paths) > 2:
result.error = 'Too many fingers seen. Must have 2 for a pinch/zoom.'
return result
class DrumrollValidator(BaseValidator):
""" Validator to check that two fingers are separated during a drumroll
All points from the same finger should be within 2 circles of a given radius
Essentially the issue this is checking for is that when two fingers are
tapping in quick succession, it's not uncommon for the pad to give them the
same tracking ID. Worse yet, some pads will actually interpolate a few
points in between the fingers to make up for the movement it "missed."
Example:
To verify that the max radius of all minimal enclosing circles generated
by alternately tapping the index and middle fingers is within 2.0 mm.
DrumrollValidator('<= 2.0')
"""
def __init__(self, criteria_str, mf=None):
name = self.__class__.__name__
desc = ("When performing a drum roll on the pad, are there any "
"interpolated points reported between the two fingers? This is "
"somewhat tricky, but essentially for a drum roll gesture two "
"fingers are quickly tapping, alternating between the two finger "
"(like drumsticks during a drum roll). This often is difficult "
"for a touch device to tell what's happening, and it can fail by "
"thinking that a single finger, simply moved quickly back and "
"forth. Gesture interpreters can reasonably handle the case where "
"both fingers are given the same tracking ID, but if there are "
"interpolated positions reported between the fingers there is no "
"way to know what's happening. This validator makes sure there is "
"nothing like that happening, by clustering the points into two "
"groups (one for each finger in the gesture) and making sure they "
"can each fit into a small circle. The validator uses the largest "
"circle's radius to check that this gesture looks how we expect.")
super(DrumrollValidator, self).__init__(criteria_str, mf, name, desc)
def _GetTwoFarthestPoints(self, fingers):
""" Find the two points that are farthest apart in the readings """
if len(fingers) <= 1:
return None, None
max_distance = float('-inf')
two_farthest_points = (None, None)
for p1 in fingers:
for p2 in fingers:
distance = self._Distance(p1, p2)
if distance > max_distance:
two_farthest_points = (p1, p2)
max_distance = distance
return two_farthest_points
def _FindTwoFarthestClusters(self, fingers):
""" Separate all the points where these fingers are into two separated
clusters. This is done by finding the two farthest apart points, then
grouping the remaining points based on their proximity to those two.
"""
p1, p2 = self._GetTwoFarthestPoints(fingers)
if p1 is None or p2 is None:
return [], []
cluster1 = set([f for f in fingers
if self._Distance(f, p1) < self._Distance(f, p2)])
cluster2 = set([f for f in fingers
if self._Distance(f, p1) >= self._Distance(f, p2)])
return cluster1, cluster2
def Validate(self, snapshots):
""" For each tracking ID, the events should all be able to be enclosed in
a circle with a radius which is scored by this validator.
"""
paths = self._SeparatePaths(snapshots)
radii = []
# for each finger, see how big the two radii are and store them all
for path in paths:
clusters = self._FindTwoFarthestClusters(path)
radii += [self._MinimumEnclosingRadius(cluster) for cluster in clusters]
result = Result()
result.name = self.name
result.description = self.description
result.units = 'mm'
result.criteria = self.criteria_str
if radii:
# The biggest radius is the value we will be scoring
result.observed = max(radii)
else:
result.observed = float('inf')
result.error = ('Unable to determine radii. Perhaps not enough '
'events were collected.')
result.score = self.fc.mf.grade(result.observed)
return result
class PhysicalClickValidator(BaseValidator):
""" Validator to check for the number of physical clicks with a given number
of fingers.
Example:
To verify that the gesture included a single, 1-finger physical click
PhysicalClickValidator('== 1', fingers=1)
"""
def __init__(self, criteria_str, fingers, mf=None):
name = self.__class__.__name__
self.fingers = fingers
desc = ("How many physical clicks were reported (pressing the click button "
"as opposed to tap-to-click)? It also checks that the correct "
"number of fingers were seen on the pad at the time of click. For "
"example, if the test is for a two-finger physical click, but no "
"physical clicks were detected, that would be a failure. "
"Similarly, if a physical click was reported, but there was one or "
"three fingers on the touch sensor at the time, that would also be "
"a failure. In general, this is intended to be a sanity check, "
"just to make sure that the click-button works and is plumbed "
"correctly through the driver. Note: If the touch hardware being "
"tested is not intended to have a physical click button, feel free "
"to ignore this result.")
super(PhysicalClickValidator, self).__init__(criteria_str, mf, name, desc)
def _CountClicks(self, snapshots):
""" Count how many clicks there were for each number of fingers. To be
counted a the button must go down and up, and the number of fingers is
considered to be the number of fingers seen while the button was initially
pressed down. This is a slight simplification of the actual gestures
library, but is correct in the vast majority of cases, and should always
work with an ideal touch pad.
This returns a dictionary mapping the number of finger->number of clicks
eg {1: 2} would indicate there were 2, 1-finger clicks
{2: 1, 1: 3} would indicate there were 3 1-finger and 1 2-finger clicks
"""
clicks = {}
button_pressed = False
fingers_down = 0
for snapshot in snapshots:
# If the button just got pressed (rising edge)
if snapshot.button_pressed and not button_pressed:
button_pressed = True
fingers_down = len(snapshot.fingers)
# If the button just got released (falling edge)
elif button_pressed and not snapshot.button_pressed:
clicks[fingers_down] = clicks.get(fingers_down, 0) + 1
button_pressed = False
return clicks
def Validate(self, snapshots):
""" Check the how many physical clicks were seen with the # of fingers """
clicks = self._CountClicks(snapshots)
result = Result()
result.name = self.name
result.description = self.description
result.units = '#'
result.criteria = self.criteria_str
result.observed = clicks.get(self.fingers, 0)
result.score = self.fc.mf.grade(result.observed)
return result
class HysteresisValidator(BaseValidator):
""" Validator to check if the finger position jumps initially when it starts
moving.
This is to check for the issue of too much "movement hysteresis" being
applied in the FW. Some touch devices try to prevent jitter by imposing a
minimum distance a finger must move before it starts reporting the movements.
This works really well, but it makes it impossible to make fine (single
pixel) adjustments to the cursor position with the touchpad if this value is
set too high.
When the movement hysteresis is too high, you can tell because there will
be a large gap (relative to the other events' spacings) between the first
two positions of the finger. This Validator computes the distance between
the first and second locations the finger reports as well as the distance
between the second and third locations and computes the ratio of those
values. If this ratio is skewed, then we know there was a high movement
hysteresis that would have resulted in a cursor jump.
Example:
To make sure the first gap is no more than 2x the size of the next gap:
HysteresisValidator('<= 2.0')
"""
def __init__(self, criteria_str, mf=None):
name = self.__class__.__name__
desc = ("Is there a disproportionately large gap between the first "
"reported position of a finger, and the second after it has "
"started to move? This validator tries to detect firmware that "
"has too much movement hysteresis, which is another way of saying "
"touch devices that won't report any movement until it's passed "
"some large threshold. This is commonly done to reduce jitter. "
"If the firmware doesn't report any motion until the finger has "
"moved, say, 5mm, this will drastically reduce jitter, but at the "
"cost of small, precise movements. This is measured by looking at "
"the gaps between the first reported positions. If the ratio of "
"the first and second gaps is high, then we know there was a high "
"movement hysteresis which is a failure.")
super(HysteresisValidator, self).__init__(criteria_str, mf, name, desc)
def _FindNextDistinctLocation(self, path, start_location):
for i in range(start_location, len(path)):
if self._Distance(path[i], path[start_location]) > 0.0:
return i, path[i]
return None, None
def Validate(self, snapshots):
""" Check for a large jump at the beginning of the finger's path. """
error = None
try:
# First find the finger to test. This will be whichever tracking id
# has the most events, so it's easy to tell.
paths = self._SeparatePaths(snapshots)
path = sorted(paths, key=lambda x: -len(x))[0]
# Find the first packet and the next two that moved. they may not be the
# adjacent packets if the x/y locations stayed the same, but pressure or
# some other values changed.
point0 = path[0]
point1_idx, point1 = self._FindNextDistinctLocation(path, 0)
_, point2 = self._FindNextDistinctLocation(path, point1_idx)
# Find the distances between these points
distance1 = self._Distance(point0, point1)
distance2 = self._Distance(point1, point2)
# Compute the ratio between them to see if there was a jump
ratio = distance1 / distance2
except:
# If something fails (eg: not enough points) set the ratio to infinity
ratio = float('inf')
error = 'Not enough distinct events seen'
result = Result()
result.name = self.name
result.description = self.description
result.units = '*ratio*'
result.criteria = self.criteria_str
result.observed = ratio
result.score = self.fc.mf.grade(result.observed)
if error:
result.error = error
return result
class TapAccuracyValidator(BaseValidator):
""" This validator is designed to compare how close in X/Y a
series of repeated taps are reported. These values will be unreliable
if the taps are executed by a human, but with a robot they should
be very close together.
Example:
To verify that the all the taps are within 1mm of eachother
TapAccuracyValidator('<= 0.5')
"""
def __init__(self, criteria_str, mf=None):
name = self.__class__.__name__
desc = ("How accurate are a series of taps in the same place? This "
"validator is intended to be run on a gesture consisting if one "
"finger repeatedly tapping in the same place. It then computes "
"the minimum radius of a circle that contains all of the points "
"reported and makes sure that it is small. If the value is large "
"that means that multiple taps in the same location have a wide "
"variation in the (x, y) positions that are reported and will "
"result in imprecise taps.")
super(TapAccuracyValidator, self).__init__(criteria_str, mf, name, desc)
def Validate(self, snapshots):
""" Check the how many physical clicks were seen with the # of fingers """
all_points_reported = []
for snapshot in snapshots:
all_points_reported.extend(snapshot.fingers)
radius = self._MinimumEnclosingRadius(all_points_reported)
result = Result()
result.name = self.name
result.description = self.description
result.units = 'mm'
result.criteria = self.criteria_str
result.observed = radius
result.score = self.fc.mf.grade(result.observed)
return result
class DiscardInitialSecondsValidatorWrapper(BaseValidator):
""" This validator wraps another validator and run that validator on the
gesture, but with the first bit of time removed.
This is used to more accureately match our spec for noise immunity. The spec
allows for a bit of time before the touch device needs to return to normal
funtionality after electrical noise increases. Using this validator you can
do things like check the linearity of a line "after the first 2 seconds."
"""
def __init__(self, validator, mf=None, initial_seconds_to_discard=1):
self.validator = validator
self.initial_seconds_to_discard = initial_seconds_to_discard
name = self.__class__.__name__
desc = ("This is the noisy version of another validator, which means that "
"the first %d seconds of the events were ignored. These first "
"events are flattened down, and then the validator is run only on "
"what remains, so as to allow the touch device time to react to "
"the electrical noise being injected. The underlying validator's "
"description reads: %s")
super(DiscardInitialSecondsValidatorWrapper, self).__init__(
validator.criteria_str, mf, name, desc)
def Validate(self, snapshots):
""" Remove the first few snapshots from the list then pass them on to the
sub-validator for validation.
"""
if len(snapshots) > 0:
# Use the timestamp of the first event, then add the amount of time being
# discarded to compute a cutoff time.
start_time = snapshots[0].syn_time
new_start_time = start_time + self.initial_seconds_to_discard
# Separate the snapshots that occurred before/after the cutoff
snapshots_before_cutoff = [s for s in snapshots
if s.syn_time <= new_start_time]
snapshots_after_cutoff = [s for s in snapshots
if s.syn_time > new_start_time]
# Compress the first 1 second's events and insert a new, fake event
# exactly at the new time cutoff so we don't ignore contacts already on
# the pad before.
last_snapshot_before_cutoff = snapshots_before_cutoff[-1]
flattened_snapshot = last_snapshot_before_cutoff._replace(
**{'syn_time': new_start_time})
trimmed_snapshots = [flattened_snapshot] + snapshots_after_cutoff
# Finally pass the trimmed snapshots on to the sub-validator
result = self.validator.Validate(trimmed_snapshots)
else:
result = self.validator.Validate([])
# Modifying the result's name to indicate some values were trimmed.
result.name = 'Noisy%s' % result.name
result.description = self.description % (self.initial_seconds_to_discard,
result.description)
return result