blob: f828541b339c24d6ee3e95e564ce1153c39f3676 [file] [log] [blame]
"""
Copyright (c) 2019, OptoFidelity OY
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
3. All advertising materials mentioning features or use of this software must display the following acknowledgement: This product includes software developed by the OptoFidelity OY.
4. Neither the name of the OptoFidelity OY nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY
DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
"""
#Analysis functions for analyzing measurements
import numpy as np
from .settings import settings, precision
import math
from collections import deque
from decimal import Decimal
import TPPTAnalysisSW.transform2d as transform2d
from .plotinfo import *
#
# Rounding
#
def round_dec(number):
if number is None:
return None
else:
return Decimal.quantize(Decimal(number), precision)
def round_dec_array(number_array):
rounded = []
for item in number_array:
rounded.append(Decimal.quantize(Decimal(item), precision))
return rounded
def float_for_db(value):
"""
Prepare float value for database.
Often float values are cast to numpy float or are set to None by default and these
are not compatible with e.g. mysql DECIMAL.
"""
if value is None or math.isnan(value):
return None
return float(value)
def float_for_db_array(number_array):
rounded = []
for item in number_array:
rounded.append(float_for_db(item))
return rounded
#
# Checking if value is NaN (not a number)
#
def is_nan(value):
return value != value
#
# Edge analysis for tap and multifinger tap (and others?)
#
def is_edge_point(point, dutinfo):
''' Checks if given target point is in the edge area of DUT '''
# Check if we are in the edge area
edge = False
if point[0] <= settings['edgelimit']:
edge = True
elif point[1] <= settings['edgelimit']:
edge = True
elif point[0] >= dutinfo.dimensions[0] - float(settings['edgelimit']):
edge = True
elif point[1] >= dutinfo.dimensions[1] - float(settings['edgelimit']):
edge = True
return edge
def get_max_error(point, dutinfo):
''' Returns the maximum error for given target point (tests edge area) '''
max_error = float('nan')
edge = False
# Check if we should do edge analysis
if settings['edgelimit'] >= 0:
edge = is_edge_point(point,dutinfo)
if edge:
# edge area
max_error = settings['edgepositioningerror']
else:
# Normal point
max_error = settings['maxposerror']
return max_error
#
# Coordinate transform functions
#
def panel_to_target(points, dutinfo):
""" Maps panel pixel coordinates - (x,y) tuple or list of tuples - to target coordinates. """
return panel_to_target_transform(dutinfo).transform(points)
def panel_to_target_angle(angle):
""" Maps panel angles (radians) to degrees. """
return round_dec(np.degrees(angle))
def panel_to_target_transform(dutinfo):
""" Returns the Transform2D object that does the transform from panel to target coordinate system """
dimensions = dutinfo.dimensions # Size of target (in mm)
resolution = dutinfo.digitizer_resolution # Resolution of target (in pixels)
offset = dutinfo.offset # Offset of panel->target conversion (in mm)
# First: possible switches
tinit = transform2d.Transform2D.identity()
if dutinfo.switchxy:
tinit = tinit + transform2d.Transform2D([[0, 1, 0], [1, 0, 0]])
if dutinfo.flipx:
# Make a mirroring transform along x-axis
tinit = tinit + transform2d.Transform2D([[-1, 0, resolution[0]], [0, 1, 0]])
if dutinfo.flipy:
tinit = tinit + transform2d.Transform2D([[1, 0, 0], [0, -1, resolution[1]]])
# Pixels per mm values
sx = float(dimensions[0])/float(resolution[0])
sy = float(dimensions[1])/float(resolution[1])
scale = transform2d.Transform2D.scale(sx,sy)
toffset = transform2d.Transform2D.offset(offset[0],offset[1])
transition = tinit + scale + toffset
return transition
def robot_to_target(points, dutinfo):
"""Maps robot pixel coordinates - (x,y) tuple or list of tuples - to target coordinates."""
# Currently does nothing
return points
def robot_to_target_angle(angle, dutinfo):
"""Maps robot angles (degrees) to radians. """
# Currently does nothing
return angle
def robot_to_target_transform(dutinfo):
""" Returns the Transform2D object that does the transform from robot to target coordinate system """
# Currently does nothing
return transform2d.Transform2D.offset(0,0) # Identity
def target_to_swipe(points, swipe_start, swipe_end):
"""Maps swipe (target) coordinates - (x,y) tuple or list of tuples - to swipe (length, offset) coordinates."""
return target_to_swipe_transform(swipe_start, swipe_end).transform(points)
def target_to_swipe_transform(swipe_start, swipe_end):
""" Returns the Transform2D object that does the transform from swipe (target) coordinates to swipe (length, offset) coordinates """
direction = (swipe_end[0] - swipe_start[0], swipe_end[1] - swipe_start[1]) # vector swipe start->end
angle = math.atan2(direction[1], direction[0]) # Angle of the swipe (start->end)
transform = transform2d.Transform2D.offset(-swipe_start[0], -swipe_start[1]) + transform2d.Transform2D.rotate_radians(-angle)
return transform
#
# Test session information functions
#
def panel_mm_per_pixel(dutinfo):
""" Returns the pixels per mm value of the target panel in a tuple (x_resolution, y_resolution) """
dimensions = dutinfo.dimensions
resolution = dutinfo.digitizer_resolution
sx = float(dimensions[0])/float(resolution[0])
sy = float(dimensions[1])/float(resolution[1])
return (sx, sy)
#
# Analysis functions
#
def analyze_swipe_jitter(points, window_length=10.0):
""" Analyzes jitter for swipe points that have been transformed to coordinate system
(swipe-direction, perpendicular-to-swipe) with origo at swipe begin and positive x-axle to
swipe direction. Jitter is calculated with sliding window of length window_length
Returns dictionary {'jitters' (list), 'max_jitter', 'backwards_points' (count), 'repeated_points' (count)}
First point jitter is always None, and in case of
backwards movement or repeated measurements jitter is float('nan') to signal failed point """
assert(window_length > 0.0)
if len(points) == 0:
return{'jitters': [],
'jitters_no_none': [],
'jitter_avg': float('nan'),
'max_jitter': float('nan'),
'jitter_stdev': float('nan'),
'backwards_points': float('nan'),
'repeated_points': float('nan')}
jitters = []
previous_x = None
previous_y = None
backwards_points = 0
repeated_points = 0
window = deque()
max_jitter = None
for point in points:
if previous_x is None:
previous_x = point[0]
previous_y = point[1]
jitters.append(None)
window.append(point)
elif point[0] < previous_x:
# Backwards movement
backwards_points += 1
jitters.append(float('nan'))
elif point[0] == previous_x and point[1] == previous_y:
# Repeated measurement
repeated_points += 1
jitters.append(float('nan'))
else:
# Moving forward or at least not backwards...
window.append(point)
while window[0][0] < (point[0] - window_length):
window.popleft() # Can never remove the point itself
# Find out the minimum and maximum offsets in window
offsets = [p[1] for p in window]
window_min = min(offsets)
window_max = max(offsets)
jitter = window_max - window_min # Peak-to-peak
jitters.append(jitter)
if max_jitter is None or jitter > max_jitter:
max_jitter = jitter
# None and NaN values need to be removed for statistical calculation.
jitters_no_none = [value for value in jitters if value is not None and not math.isnan(value)]
jitter_avg = None
jitter_stdev = None
if len(jitters_no_none) > 0:
jitter_avg = np.mean(jitters_no_none)
# Jitter is a deviation quantity so the standard deviation is the RMS of jitter values.
jitter_stdev = np.sqrt(np.mean(np.power(jitters_no_none, 2)))
results = {'jitters': jitters,
'jitters_no_none': jitters_no_none,
'jitter_avg': jitter_avg,
'max_jitter': max_jitter,
'jitter_stdev': jitter_stdev,
'backwards_points': backwards_points,
'repeated_points': repeated_points}
return results
def analyze_swipe_linearity(points):
"""
Calculates linear fit for a single swipe line
Determine max, avg, stdev and rms errors from linear fit
"""
if len(points) == 0:
results = {'linear_error': [],
'fitted_y': [],
'lin_error_avg': float('nan'),
'lin_error_stdev': float('nan'),
'lin_error_max': float('nan'),
'lin_error_rms': float('nan'),
'linear_fit': []}
return results
x = []
y = []
for point in points:
x.append(point[0])
y.append(point[1])
# Linearfit to fit the data
# Linearcoef has slope (1) and intercept (2)
linearcoef = np.polyfit(x, y, 1)
linearfit = np.polyval(linearcoef, x)
# Starndard form of linear equation
# ax + by + c = 0
a = linearcoef[0]
b = -1
c = linearcoef[1]
# Point y-coordinates in the "fitted line coordinate frame" where the fitted line is the x-axis.
# These are signed values, not unsigned distances.
fitted_y = (a * np.array(x) + b * np.array(y) + c) / math.sqrt(a**2 + b**2)
# Max deviation calc: orthogonal distance
# from fit line to data set
lin_error = np.absolute(fitted_y)
lin_error_max = max(lin_error)
lin_error_avg = np.mean(lin_error)
lin_error_rms = np.sqrt(np.mean(np.power(lin_error, 2)))
# Standard deviation is calculated from the y-coordinates in the "fitted line coordinate frame".
# Sample averaging is used so ddof parameter is 1.
lin_error_stdev = np.std(fitted_y, ddof=1)
results = {'linear_error': lin_error.tolist(),
'fitted_y': fitted_y.tolist(),
'lin_error_avg': lin_error_avg,
'lin_error_stdev': lin_error_stdev,
'lin_error_max': lin_error_max,
'lin_error_rms': lin_error_rms,
'linear_fit': linearfit}
return results
def analyze_swipe_offset(points):
'''
Calculates statistical values for swipe offset values
:param points: list of tuples (distance, offset) on robot
drawn line
'''
if len(points) == 0:
return {'offset_mean': float('nan'),
'offset_stdev': float('nan'),
'offset_max': float('nan'),
'offset_rms_mean': float('nan'),
'offsets': []}
offsets = [abs(point[1]) for point in points]
# Standard deviation is calculated from the signed y-coordinates in the swipe line frame.
# Sample averaging is used so ddof parameter is 1.
offset_stdev = np.std([point[1] for point in points], ddof=1)
return {'offset_mean': np.mean(offsets),
'offset_stdev': offset_stdev,
'offset_max': max(offsets),
'offset_rms_mean': np.sqrt(np.mean(np.power(offsets, 2))),
'offsets': offsets
}
def calculate_report_rates(points):
previous_timestamp = 0.0
report_rates = []
for point in points:
if previous_timestamp == 0.0:
previous_timestamp = point.time
else:
delay = point.time - previous_timestamp
if delay > 0:
report_rate = 1.0 / (delay / 1000.0)
report_rates.append(report_rate)
previous_timestamp = point.time
return report_rates
#
# Multifinger functions
#
def find_closest_id_match(normsdict):
""" Finds the closest match for set of ids to an array. Used in the
multifinger tap and multifinger swipe. The normsdict is a dictionary,
whose keys are the finger_ids from database, and each dictionary element
contains array of distances to the points (lines) to which the ids are to be mapped.
The function tries to find the closest match from id to a point and returns
an array, where each index holds the closest point (line) """
numids = len(normsdict.keys())
# Attempt #1: closest point
fingerids = None
for id, norms in normsdict.items():
if fingerids is None:
# Only in the first round
numpoints = len(norms)
fingerids = [None] * numpoints
mindist = np.argmin(norms)
if fingerids[mindist] is None:
fingerids[mindist] = [id]
else:
fingerids[mindist].append(id)
if fingerids.count(None) == 0 or fingerids.count(None) == numpoints - numids:
# We have a match
#print "Match from round #1: " + str(fingerids)
pass
else:
# Attempt #2: find the closest match using brute force search. As a norm use minimum of sum of squares
# WARNING: This is slow algorithm if number fo finger_ids grows too large...
fids = find_smallest_error(normsdict)
# Found match
#print "Match from round #2: " + str(fids)
fingerids = fids
return fingerids
def find_smallest_error(normsdict):
""" Finds a best match for least-square matching problem, where each point is
referenced only one, or every point has just one id or less """
# Recursive algorithm using brute force search
# Current best norm is used as a threshold - if the norm would be larger, search is not continued
ids = list(normsdict.keys())
norm, smallest_fids = find_smallest_remaining(normsdict, ids, [None] * len(normsdict[ids[0]]), 0.0, float('inf'))
return smallest_fids
def find_smallest_remaining(normsdict, unset_ids, fixed_ids, current_norm, threshold_norm):
''' Recursive algorithm: unset_ids are the ids not yet set, fixed ids is the array,
to which the ids are set/collected, threshold norm is the norm, after which the calculation is cut off '''
# Recursion round: take the first id
current_id = unset_ids[0]
remaining_ids = unset_ids[1:]
min_norm = threshold_norm
min_ids = None
for i, dist in enumerate(normsdict[current_id]):
if current_norm + dist >= min_norm:
# Do not continue calculation -> the result would be larger
# than current minimum
continue
# Fix the current id at position i (attempt)
new_fixed = list(fixed_ids) # Have to make a copy - lists are mutable
if new_fixed[i] is None:
new_fixed[i] = [current_id]
else:
# Check if this attempt would be legal
if new_fixed.count(None) > len(remaining_ids):
# Not enough ids left to fill the remaining empty places
continue
new_fixed[i] = list(new_fixed[i]) # Ditto
new_fixed[i].append(current_id)
# Calculate the new norm with this assumption
if len(remaining_ids) > 0:
new_norm, new_ids = find_smallest_remaining(normsdict, remaining_ids, new_fixed, current_norm + dist**2, min_norm)
else:
# This was last id
new_norm = current_norm + dist**2
new_ids = new_fixed
if new_ids is not None:
max_length = max([0 if l is None else len(l) for l in new_ids])
if None in new_ids and max_length > 1:
# Illegal solution: empty spaces combined with arrays with length > 1
pass
elif new_norm < min_norm:
min_norm = new_norm
min_ids = new_ids
return (min_norm, min_ids)
def bounding_box(vector):
""" Returns bounding box for an array of points [[x,y], [x,y], ...] """
min_x, min_y = np.min(vector, axis=0)
max_x, max_y = np.max(vector, axis=0)
return np.array([(min_x, min_y), (max_x, min_y), (max_x, max_y), (min_x, max_y)])
def filter_points(points):
"""
Return list that has only points between the first touch down event and
the last touch up event including the touch down and touch up event points.
In case either does not exist, returns an empty list.
"""
events = [point.event for point in points]
try:
# Index to first instance of touch down event.
ix_touch_down = events.index(0)
# Index to last instance of touch up event.
ix_touch_up = len(events) - 1 - events[::-1].index(1)
except ValueError: # Raised by index().
return []
return points[ix_touch_down:ix_touch_up+1]