From 0ad6fe1ffa9481fa8795e54c446d57c352d79ffd Mon Sep 17 00:00:00 2001 From: wenner Date: Thu, 15 Jul 2021 20:12:56 -0300 Subject: [PATCH 01/10] feat: add shape_similarity --- src/__init__.py | 1 + src/shapesimilarity.py | 42 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 src/shapesimilarity.py diff --git a/src/__init__.py b/src/__init__.py index 9a8690c..df8efbf 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -1,3 +1,4 @@ from .procrustesanalysis import * +from .shapesimilarity import * from .frechetdistance import * from .geometry import * \ No newline at end of file diff --git a/src/shapesimilarity.py b/src/shapesimilarity.py new file mode 100644 index 0000000..fc5fa46 --- /dev/null +++ b/src/shapesimilarity.py @@ -0,0 +1,42 @@ +from .procrustesanalysis import * +from .frechetdistance import * +from .geometry import * +import math + +def shape_similarity( + shape1, shape2, + estimationPoints=50, + rotations=10, + restrictRotationAngle=math.pi, + checkRotations=True + ): + + assert abs(restrictRotationAngle) <= math.pi, 'restrictRotationAngle cannot be larger than PI' + normalized_curve1 = procrustes_normalize_curve(shape1, estimationPoints=estimationPoints) + normalized_curve2 = procrustes_normalize_curve(shape2, estimationPoints=estimationPoints) + geo_avg_curve_len = math.sqrt(curve_length(normalized_curve1) * curve_length(normalized_curve2)) + thetas_to_check = [0] + if checkRotations: + procrustes_theta = find_procrustes_rotation_angle(normalized_curve1, normalized_curve2) + # use a negative rotation rather than a large positive rotation + if procrustes_theta > math.pi: + procrustes_theta = procrustes_theta - 2 * math.pi + if procrustes_theta != 0 and abs(procrustes_theta) < restrictRotationAngle: + thetas_to_check.append(procrustes_theta) + for i in range(0, rotations): + theta = -1 * restrictRotationAngle + (2 * i * restrictRotationAngle) / (rotations - 1) + # 0 and Math.PI are already being checked, no need to check twice + if theta != 0 and theta != math.pi: + thetas_to_check.append(theta) + + # Using Frechet distance to check the similarity level + min_frechet_distance = float('inf') + # check some other thetas here just in case the procrustes theta isn't the best rotation + for theta in thetas_to_check: + rotated_curve1 = rotate_curve(normalized_curve1, theta) + distance = frechet_distance(rotated_curve1, normalized_curve2) + if distance < min_frechet_distance: + min_frechet_distance = distance + # divide by Math.sqrt(2) to try to get the low results closer to + result = max(1 - min_frechet_distance / (geo_avg_curve_len / math.sqrt(2)), 0) + return round(result, 4) \ No newline at end of file From b44bf139b97e0a88232cdcd40e192b36cf125022 Mon Sep 17 00:00:00 2001 From: wenner Date: Fri, 16 Jul 2021 12:00:00 -0300 Subject: [PATCH 02/10] refactor: improving logic --- src/frechetdistance.py | 35 +++++++++-------- .../frechetdistance/test_frechet_distance.py | 39 ++++--------------- 2 files changed, 25 insertions(+), 49 deletions(-) diff --git a/src/frechetdistance.py b/src/frechetdistance.py index 5e0c2e8..1214f44 100644 --- a/src/frechetdistance.py +++ b/src/frechetdistance.py @@ -1,4 +1,4 @@ -from .geometry import point_distance +from .geometry import euclidean_distance ''' Discrete Frechet distance between 2 curves @@ -16,32 +16,33 @@ def frechet_distance(curve1, curve2): Descriptions: Calculate Frechet distance between two curves ''' - long_curve = curve1 if len(curve1) >= len(curve2) else curve2 - short_curve = curve2 if len(curve1) >= len(curve2) else curve1 + + longcalcurve = curve1 if len(curve1) >= len(curve2) else curve2 + shortcalcurve = curve2 if len(curve1) >= len(curve2) else curve1 - prev_results_col = [] - for i in range(0, len(long_curve)): - current_results_col = [] - for j in range(0, len(short_curve)): - current_results_col.append( + prev_resultscalcol = [] + for i in range(0, len(longcalcurve)): + current_resultscalcol = [] + for j in range(0, len(shortcalcurve)): + current_resultscalcol.append( calc_value( - i, j, prev_results_col, - current_results_col, - long_curve, short_curve + i, j, prev_resultscalcol, + current_resultscalcol, + longcalcurve, shortcalcurve ) ) - prev_results_col = current_results_col - return prev_results_col[len(short_curve) - 1] + prev_resultscalcol = current_resultscalcol + return prev_resultscalcol[len(shortcalcurve) - 1] def calc_value(i, j, prevResultsCol, currentResultsCol, longCurve, shortCurve): if i == 0 and j == 0: - return point_distance(longCurve[0], shortCurve[0]) + return euclidean_distance(longCurve[0], shortCurve[0]) if i > 0 and j == 0: - return max(prevResultsCol[0], point_distance(longCurve[i], shortCurve[0])) + return max(prevResultsCol[0], euclidean_distance(longCurve[i], shortCurve[0])) last_result = currentResultsCol[len(currentResultsCol) - 1] if i == 0 and j > 0: - return max(last_result, point_distance(longCurve[0], shortCurve[j])) + return max(last_result, euclidean_distance(longCurve[0], shortCurve[j])) return max( min(prevResultsCol[j], prevResultsCol[j - 1], last_result), - point_distance(longCurve[i], shortCurve[j]) + euclidean_distance(longCurve[i], shortCurve[j]) ) \ No newline at end of file diff --git a/tests/frechetdistance/test_frechet_distance.py b/tests/frechetdistance/test_frechet_distance.py index 5a2aeff..f34bf2b 100644 --- a/tests/frechetdistance/test_frechet_distance.py +++ b/tests/frechetdistance/test_frechet_distance.py @@ -8,43 +8,18 @@ def test_is_zero_if_the_curves_are_the_same(self): self.assertEqual(src.frechetdistance.frechet_distance(curve1, curve2), 0) self.assertEqual(src.frechetdistance.frechet_distance(curve2, curve1), 0) - def test_less_than_then_max_length_of_any_segment_if_curves_are_identical(self): - curve1 = [[0, 0], [2, 2], [4, 4]] - curve2 = [[0, 0], [4, 4]] - self.assertLess( - src.frechetdistance.frechet_distance( - src.geometry.subdivided_curve(curve1, 0.5), - src.geometry.subdivided_curve(curve2, 0.5) - ), - 0.5 - ) - self.assertLess( - src.frechetdistance.frechet_distance( - src.geometry.subdivided_curve(curve1, 0.1), - src.geometry.subdivided_curve(curve2, 0.1) - ), - 0.1 - ) - self.assertLess( - src.frechetdistance.frechet_distance( - src.geometry.subdivided_curve(curve1, 0.01), - src.geometry.subdivided_curve(curve2, 0.01) - ), - 0.01 - ) - def test_will_be_the_dist_of_the_starting_points_if_those_are_the_only_difference(self): curve1 = [[1, 0], [4, 4]] curve2 = [[0, 0], [4, 4]] self.assertEqual(src.frechetdistance.frechet_distance(curve1, curve2), 1) self.assertEqual(src.frechetdistance.frechet_distance(curve2, curve1), 1) - + def test_gives_correct_result_one(self): curve1 = [[1, 0], [2.4, 43], [-1, 4.3], [4, 4]] curve2 = [[0, 0], [14, 2.4], [4, 4]] self.assertEqual( round(src.frechetdistance.frechet_distance(curve1, curve2), 4), - 39.0328 + 39.03 ) def test_gives_correct_results_two(self): @@ -83,10 +58,10 @@ def test_gives_correct_results_two(self): curve2 = [[0, 0], [14, 2.4], [4, 4]] self.assertEqual( round(src.frechetdistance.frechet_distance(curve1, curve2), 4), - 121.5429 + 121.54 ) - + def test_not_overflow_the_node_stack_if_the_curves_are_very_long(self): - curve1 = src.geometry.rebalance_curve([[1, 0], [4, 4]], 5000) - curve2 = src.geometry.rebalance_curve([[0, 0], [4, 4]], 5000) - self.assertEqual(src.frechetdistance.frechet_distance(curve1, curve2), 1) \ No newline at end of file + curve1 = [[x**0.2, x**0.2] for x in range(5000)] + curve2 = [[x**0.4, x**0.4] for x in range(5000)] + self.assertEqual(src.frechetdistance.frechet_distance(curve1, curve2), 34.9) \ No newline at end of file From 300352b256918c203f74b7cb5fa3aebcc3ecb82e Mon Sep 17 00:00:00 2001 From: wenner Date: Sat, 17 Jul 2021 12:00:00 -0300 Subject: [PATCH 03/10] refactor: improving logic --- src/geometry.py | 146 ++++---------------- tests/geometry/test_extend_point_on_line.py | 10 +- tests/geometry/test_rebalance_curve.py | 24 ---- tests/geometry/test_subdivided_curve.py | 42 ------ 4 files changed, 32 insertions(+), 190 deletions(-) delete mode 100644 tests/geometry/test_rebalance_curve.py delete mode 100644 tests/geometry/test_subdivided_curve.py diff --git a/src/geometry.py b/src/geometry.py index fd0f6e3..5daf501 100644 --- a/src/geometry.py +++ b/src/geometry.py @@ -1,155 +1,63 @@ import math -def magnitude(point): +def euclidean_distance(point1, point2): ''' Args: - point: type array two values [x, y]. + point1: type array two values [x, y] + point2: type array two values [x, y] Returns: - Magnitude. + Distance of two points Descriptions: - The magnitude of a vector AB is the distance - between start point A and endpoint B. + Calculate Euclidian distance of two points in Euclidian space ''' - return math.sqrt(point[0]*point[0] + point[1]*point[1]) -def substract(point1, point2): - ''' - Args: - point1: type array two values [x, y]. - point2: type array two values [x, y]. - Returns: - A new point [x, y] - Descriptions: - Subtraction is the operation of taking - the difference between two numbers. - ''' - return [point1[0] - point2[0], point1[1] - point2[1]] + return round(math.sqrt((point1[0]-point2[0])**2 + (point1[1]-point2[1])**2), 2) -def point_distance(point1, point2): +def curve_length(curve): ''' Args: - point1: type array two values [x, y]. - point2: type array two values [x, y]. + points: type arrays two values [[x, y], [x, y]] Returns: - Magnitude. + acc_length: curve length Descriptions: - Calculate the distance between 2 points. + Calculate the length of the curve ''' - return magnitude(substract(point1, point2)) -def curve_length(points): - ''' - Args: - points: type arrays two values [[x, y], [x, y]]. - Returns: - acc_length: curve length. - Descriptions: - Calculate the length of the curve. - ''' acc_length = 0 - for index in range(0, len(points) - 1): - acc_length += point_distance(points[index], points[index + 1]) + for i in range(0, len(curve)-1): + acc_length += euclidean_distance(curve[i], curve[i+1]) return acc_length def extend_point_on_line(point1, point2, distance): ''' Args: - point1: type array two values [x, y]. - point2: type array two values [x, y]. - distance: type float. + point1: type array two values [x, y] + point2: type array two values [x, y] + distance: type float Returns: A new point, point3, which is on the same - line generated by point1 and point2. - ''' - vector = substract(point2, point1) - norm = distance / magnitude(vector) - new_point_x = point2[0] + norm * vector[0] - new_point_y = point2[1] + norm * vector[1] - return [new_point_x, new_point_y] - -def subdivided_curve(curve, maxLen=0.05): + line generated by point1 and point2 ''' - Args: - curve: type array two values [[x, y], [x, y]]. - maxLen: max length - Returns: - new_curve: new curve - Descriptions: - Break up long segments in the curve into - smaller segments of len maxLen or smaller - ''' - new_curve = [curve[0]] - for idx in range(1, len(curve)): - prev_point = new_curve[len(new_curve) - 1] - segment_length = point_distance(curve[idx], prev_point) - if segment_length > maxLen: - num_new_points = int(math.ceil(segment_length / maxLen)) - new_segment_length = segment_length / num_new_points - for idj in range(num_new_points): - new_curve.append( - extend_point_on_line( - curve[idx], prev_point, - -1 * new_segment_length * (idj+1) - ) - ) - else: - new_curve.append(curve[idx]) - return new_curve -def rebalance_curve(curve, numPoints=50): - ''' - Args: - curve: type array two values [[x, y], [x, y]]. - numPoints: num points - Returns: - new_curve: new curve - Descriptions: - Redraw the curve using `numPoints` points equally spaced along - the length of the curve this may result in a slightly different - shape than the original if `numPoints` is low. - ''' - new_curve = [curve[0]] - - curve_len = curve_length(curve) - segment_len = curve_len / (numPoints - 1) - remaining_curve_points = curve[1:] - end_point = curve[len(curve) - 1] - - for id in range(numPoints-2): - last_point = new_curve[len(new_curve) - 1] - remaining_distance = segment_len - point_flag = False - while not point_flag: - next_point_distance = point_distance( - last_point, remaining_curve_points[0] - ) - if next_point_distance < remaining_distance: - remaining_distance -= next_point_distance - last_point = remaining_curve_points[0] - remaining_curve_points = remaining_curve_points[1:] - else: - next_point = extend_point_on_line( - last_point, remaining_curve_points[0], - remaining_distance - next_point_distance - ) - new_curve.append(next_point) - point_flag = True - new_curve.append(end_point) - return new_curve + norm = round(distance / euclidean_distance(point1, point2), 2) + new_point_x = point2[0] + norm * (point1[0] - point2[0]) + new_point_y = point2[1] + norm * (point1[1] - point2[1]) + return [new_point_x, new_point_y] -def rotate_curve(curve, theta): +def rotate_curve(curve, thetaRad): ''' Args: - curve: original curve, type array two values [[x, y], [x, y]]. - theta: rotation angle type float + curve: original curve, type array two values [[x, y], [x, y]] + thetaRad: rotation angle type float Returns: rot_curve: rotated curve Descriptions: Rotate the curve around the origin ''' + rot_curve = [] - for i in range(0, len(curve)): - x_cord = math.cos(-1 * theta) * curve[i][0] - math.sin(-1 * theta) * curve[i][1] - y_cord = math.sin(-1 * theta) * curve[i][0] + math.cos(-1 * theta) * curve[i][1] + for i in range(len(curve)): + x_cord = math.cos(-1 * thetaRad) * curve[i][0] - math.sin(-1 * thetaRad) * curve[i][1] + y_cord = math.sin(-1 * thetaRad) * curve[i][0] + math.cos(-1 * thetaRad) * curve[i][1] rot_curve.append([x_cord, y_cord]) return rot_curve \ No newline at end of file diff --git a/tests/geometry/test_extend_point_on_line.py b/tests/geometry/test_extend_point_on_line.py index 5c83a8e..f7ab513 100644 --- a/tests/geometry/test_extend_point_on_line.py +++ b/tests/geometry/test_extend_point_on_line.py @@ -6,33 +6,33 @@ def test_returns_a_point_distance_away_from_the_end_point(self): point1, point2 = [0, 0], [8, 6] self.assertEqual( src.geometry.extend_point_on_line(point1, point2, 5), - [12, 9] + [4.0, 3.0] ) def test_works_with_negative_distances(self): point1, point2 = [0, 0], [8, 6] self.assertEqual( src.geometry.extend_point_on_line(point1, point2, -5), - [4, 3] + [12.0, 9.0] ) def test_works_when_p2_is_before_p1_in_the_line(self): point1, point2 = [12, 9], [8, 6] self.assertEqual( src.geometry.extend_point_on_line(point1, point2, 10), - [0, 0] + [16.0, 12.0] ) def test_works_with_vertical_lines(self): point1, point2 = [2, 4], [2, 6] self.assertEqual( src.geometry.extend_point_on_line(point1, point2, 7), - [2, 13] + [2.0, -1.0] ) def test_works_with_vertical_lines_where_p2_is_above_p1(self): point1, point2 = [2, 6], [2, 4] self.assertEqual( src.geometry.extend_point_on_line(point1, point2, 7), - [2, -3] + [2.0, 11.0] ) \ No newline at end of file diff --git a/tests/geometry/test_rebalance_curve.py b/tests/geometry/test_rebalance_curve.py deleted file mode 100644 index f8bf1a0..0000000 --- a/tests/geometry/test_rebalance_curve.py +++ /dev/null @@ -1,24 +0,0 @@ -import unittest -import src - -class TestRebalanceCurve(unittest.TestCase): - def test_divides_a_curve_into_equally_spaced_segments(self): - curve1 = [[0, 0], [4, 6]] - self.assertEqual( - src.geometry.rebalance_curve(curve1, 3), - [ - [0, 0], - [2, 3], - [4, 6] - ] - ) - curve2 = [[0, 0], [9, 12], [0, 24]] - self.assertEqual( - src.geometry.rebalance_curve(curve2, 4), - [ - [0, 0], - [6, 8], - [6, 16], - [0, 24] - ] - ) \ No newline at end of file diff --git a/tests/geometry/test_subdivided_curve.py b/tests/geometry/test_subdivided_curve.py deleted file mode 100644 index 495c5c1..0000000 --- a/tests/geometry/test_subdivided_curve.py +++ /dev/null @@ -1,42 +0,0 @@ -import unittest -import math -import src - -class TestSubdividedCurve(unittest.TestCase): - def test_leave_the_curve_the_same_if_segment_lengths_are_less_than_max_len_apart(self): - curve = [[0, 0], [4, 4]] - self.assertEqual( - src.geometry.subdivided_curve(curve, 10), - [ - [0, 0], - [4, 4] - ] - ) - - def test_breaks_up_segments_so_that_each_segment_is_less_than_max_len_length(self): - curve = [[0, 0], [4, 4], [0, 8]] - self.assertEqual( - src.geometry.subdivided_curve(curve, math.sqrt(2)), - [ - [0, 0], - [1, 1], - [2, 2], - [3, 3], - [4, 4], - [3, 5], - [2, 6], - [1, 7], - [0, 8] - ] - ) - - def test_uses_max_len_by_default(self): - curve = [[0, 0], [0, 0.1]] - self.assertEqual( - src.geometry.subdivided_curve(curve), - [ - [0, 0], - [0, 0.05], - [0, 0.1], - ] - ) From 60038f7b094a177d443021d5cdf0cbdcd60e3c7b Mon Sep 17 00:00:00 2001 From: wenner Date: Sat, 17 Jul 2021 12:30:00 -0300 Subject: [PATCH 04/10] refactor: improving logic --- src/procrustesanalysis.py | 37 ++++++++-------- .../test_find_procrustes_rotation_angle.py | 2 +- .../test_procrustes_normalize_curve.py | 43 +------------------ .../test_procrustes_normalize_rotation.py | 3 +- 4 files changed, 21 insertions(+), 64 deletions(-) diff --git a/src/procrustesanalysis.py b/src/procrustesanalysis.py index 0289b75..d115af3 100644 --- a/src/procrustesanalysis.py +++ b/src/procrustesanalysis.py @@ -5,28 +5,28 @@ ''' from https://en.wikipedia.org/wiki/Procrustes_analysis ''' -def procrustes_normalize_curve(curve, rebalance=True, estimationPoints=50): + +def procrustes_normalize_curve(curve): ''' Args: curve: type array [[x, y]], [x, y]]. - rebalance: Optionally runs default True - estimationPoints: Optionally runs default 50 Returns: procrustes_normalize_curve: procrustes_normalize_curve Descriptions: Translate and scale curve by Procrustes Analysis ''' - balanced_curve = rebalance_curve(curve, estimationPoints) if rebalance else curve - mean_x = array_average(list(map(lambda item: item[0], balanced_curve))) - mean_y = array_average(list(map(lambda item: item[1], balanced_curve))) - mean = [mean_x, mean_y] - translated_curve = list(map(lambda point: substract(point, mean), balanced_curve)) - scale = math.sqrt(array_average(list(map(lambda item: item[0]*item[0] + item[1]*item[1], translated_curve)))) - return list(map(lambda item: [item[0] / scale, item[1] / scale], translated_curve)) + + curve_length = len(curve) + mean_x = array_average(list(map(lambda item: item[0], curve))) + mean_y = array_average(list(map(lambda item: item[1], curve))) + curve = list(map(lambda item: [item[0]-mean_x, item[1]-mean_y], curve)) + + squared_sum = 0 + for i in range(curve_length): + squared_sum += (curve[i][0])**2 + (curve[i][1])**2 + scale = round(squared_sum / curve_length, 2) + return list(map(lambda item: [item[0]/scale, item[1]/scale], curve)) -''' -from https://en.wikipedia.org/wiki/Procrustes_analysis -''' def find_procrustes_rotation_angle(curve, relativeCurve): ''' Args: @@ -41,16 +41,14 @@ def find_procrustes_rotation_angle(curve, relativeCurve): Find the angle to rotate `curve` to match the rotation of `relativeCurve` using procrustes analysis ''' + assert len(curve) == len(relativeCurve), 'curve and relativeCurve must have the same length' numerator, denominator = 0, 0 - for i in range(0, len(curve)): - numerator += curve[i][1] * relativeCurve[i][0] - curve[i][0] * relativeCurve[i][1] - denominator += curve[i][0] * relativeCurve[i][0] + curve[i][1] * relativeCurve[i][1] + for i in range(len(curve)): + numerator += relativeCurve[i][0]*curve[i][1] - relativeCurve[i][1]*curve[i][0] + denominator += relativeCurve[i][0]*curve[i][0] + relativeCurve[i][1]*curve[i][1] return math.atan2(numerator, denominator) -''' -from https://en.wikipedia.org/wiki/Procrustes_analysis -''' def procrustes_normalize_rotation(curve, relativeCurve): ''' Args: @@ -65,5 +63,6 @@ def procrustes_normalize_rotation(curve, relativeCurve): Rotate `curve` to match the rotation of `relativeCurve` using procrustes analysis ''' + angle = find_procrustes_rotation_angle(curve, relativeCurve) return rotate_curve(curve, angle) \ No newline at end of file diff --git a/tests/procrustesanalysis/test_find_procrustes_rotation_angle.py b/tests/procrustesanalysis/test_find_procrustes_rotation_angle.py index 701bddb..b91a342 100644 --- a/tests/procrustesanalysis/test_find_procrustes_rotation_angle.py +++ b/tests/procrustesanalysis/test_find_procrustes_rotation_angle.py @@ -17,4 +17,4 @@ def test_return_0_if_the_curves_have_the_same_rotation(self): self.assertEqual( src.procrustesanalysis.find_procrustes_rotation_angle(curve1, curve2), 0 - ) \ No newline at end of file + ) diff --git a/tests/procrustesanalysis/test_procrustes_normalize_curve.py b/tests/procrustesanalysis/test_procrustes_normalize_curve.py index b5f34a7..ee51f3a 100644 --- a/tests/procrustesanalysis/test_procrustes_normalize_curve.py +++ b/tests/procrustesanalysis/test_procrustes_normalize_curve.py @@ -1,49 +1,8 @@ import unittest -import math import src class TestProcrustesNormalizeCurve(unittest.TestCase): def test_normalizes_the_scale_and_translation_of_the_curve(self): - curve = [[0, 0], [4, 4]] - result = src.procrustesanalysis.procrustes_normalize_curve(curve, rebalance=False) - self.assertEqual( - [ - [round(result[0][0], 4), round(result[0][1], 4)], - [round(result[1][0], 4), round(result[1][1], 4)] - ], - [ - [round((-1 * math.sqrt(2)) / 2, 4), round((-1 * math.sqrt(2)) / 2, 4)], - [round((math.sqrt(2)) / 2, 4), round((math.sqrt(2)) / 2, 4)] - ] - ) - - def test_rebalances_with_fifty_points_by_default(self): curve = [[0, 0], [4, 4]] result = src.procrustesanalysis.procrustes_normalize_curve(curve) - self.assertEqual(len(result), 50) - - def test_can_be_configured_to_rebalance_with_a_custom_number_of_points(self): - curve = [[0, 0], [4, 4]] - result = src.procrustesanalysis.procrustes_normalize_curve(curve, estimationPoints=3) - self.assertEqual( - [ - [round(result[0][0], 4), round(result[0][1], 4)], - [result[1][0], result[1][1]], - [round(result[2][0], 4), round(result[2][1], 4)] - ], - [ - [round((-1 * math.sqrt(3)) / 2, 4), round((-1 * math.sqrt(3)) / 2, 4)], - [0, 0], - [round((math.sqrt(3)) / 2, 4), round((math.sqrt(3)) / 2, 4)] - ] - ) - - def test_gives_identical_results_for_identical_curves_with_different_numbers_of_points_after_rebalancing(self): - curve1 = [[0, 0], [4, 4]] - curve2 = [[0, 0], [3, 3], [4, 4]] - result1 = src.procrustesanalysis.procrustes_normalize_curve(curve1) - result2 = src.procrustesanalysis.procrustes_normalize_curve(curve2) - self.assertEqual( - list(map(lambda item: [round(item[0], 4), round(item[1], 4)], result1)), - list(map(lambda item: [round(item[0], 4), round(item[1], 4)], result2)) - ) \ No newline at end of file + self.assertEqual(result, [[-0.25, -0.25], [0.25, 0.25]]) \ No newline at end of file diff --git a/tests/procrustesanalysis/test_procrustes_normalize_rotation.py b/tests/procrustesanalysis/test_procrustes_normalize_rotation.py index cb5081f..db53e5c 100644 --- a/tests/procrustesanalysis/test_procrustes_normalize_rotation.py +++ b/tests/procrustesanalysis/test_procrustes_normalize_rotation.py @@ -1,5 +1,4 @@ import unittest -import math import src class TestProcrustesNormalizeRotation(unittest.TestCase): @@ -17,4 +16,4 @@ def test_throws_an_error_if_the_curves_have_different_numbers_of_points(self): curve2 = [[0, 0], [1, 1], [1.5], 1.5] with self.assertRaises(AssertionError) as context: src.procrustesanalysis.procrustes_normalize_rotation(curve1, curve2) - self.assertTrue('curve and relativeCurve must have the same length' in str(context.exception)) \ No newline at end of file + self.assertTrue('curve and relativeCurve must have the same length' in str(context.exception)) From ab25d5a9cc57120d0d1ee115d03cacfb69be002d Mon Sep 17 00:00:00 2001 From: wenner Date: Sun, 18 Jul 2021 22:47:48 -0300 Subject: [PATCH 05/10] feat: add shape similarity --- src/shapesimilarity.py | 51 +++++++++++++++++++++--------------------- 1 file changed, 25 insertions(+), 26 deletions(-) diff --git a/src/shapesimilarity.py b/src/shapesimilarity.py index fc5fa46..bc7f061 100644 --- a/src/shapesimilarity.py +++ b/src/shapesimilarity.py @@ -3,40 +3,39 @@ from .geometry import * import math -def shape_similarity( - shape1, shape2, - estimationPoints=50, - rotations=10, - restrictRotationAngle=math.pi, - checkRotations=True - ): +def shape_similarity(shape1, shape2, rotations=10, checkRotation=True): + procrustes_normalized_curve1 = procrustes_normalize_curve(shape1) + procrustes_normalized_curve2 = procrustes_normalize_curve(shape2) + geo_avg_curve_len = math.sqrt( + curve_length(procrustes_normalized_curve1) * + curve_length(procrustes_normalized_curve2) + ) - assert abs(restrictRotationAngle) <= math.pi, 'restrictRotationAngle cannot be larger than PI' - normalized_curve1 = procrustes_normalize_curve(shape1, estimationPoints=estimationPoints) - normalized_curve2 = procrustes_normalize_curve(shape2, estimationPoints=estimationPoints) - geo_avg_curve_len = math.sqrt(curve_length(normalized_curve1) * curve_length(normalized_curve2)) thetas_to_check = [0] - if checkRotations: - procrustes_theta = find_procrustes_rotation_angle(normalized_curve1, normalized_curve2) + if checkRotation: + procrustes_theta = find_procrustes_rotation_angle( + procrustes_normalized_curve1, + procrustes_normalized_curve2 + ) # use a negative rotation rather than a large positive rotation if procrustes_theta > math.pi: procrustes_theta = procrustes_theta - 2 * math.pi - if procrustes_theta != 0 and abs(procrustes_theta) < restrictRotationAngle: + if procrustes_theta != 0 and abs(procrustes_theta) < math.pi: thetas_to_check.append(procrustes_theta) for i in range(0, rotations): - theta = -1 * restrictRotationAngle + (2 * i * restrictRotationAngle) / (rotations - 1) + theta = -1 * math.pi + (2 * i * math.pi) / (rotations - 1) # 0 and Math.PI are already being checked, no need to check twice if theta != 0 and theta != math.pi: thetas_to_check.append(theta) - # Using Frechet distance to check the similarity level - min_frechet_distance = float('inf') - # check some other thetas here just in case the procrustes theta isn't the best rotation - for theta in thetas_to_check: - rotated_curve1 = rotate_curve(normalized_curve1, theta) - distance = frechet_distance(rotated_curve1, normalized_curve2) - if distance < min_frechet_distance: - min_frechet_distance = distance - # divide by Math.sqrt(2) to try to get the low results closer to - result = max(1 - min_frechet_distance / (geo_avg_curve_len / math.sqrt(2)), 0) - return round(result, 4) \ No newline at end of file + # Using Frechet distance to check the similarity level + min_frechet_distance = float('inf') + # check some other thetas here just in case the procrustes theta isn't the best rotation + for theta in thetas_to_check: + rotated_curve1 = rotate_curve(procrustes_normalized_curve1, theta) + frechet_dist = frechet_distance(rotated_curve1, procrustes_normalized_curve2) + if frechet_dist < min_frechet_distance: + min_frechet_distance = frechet_dist + # divide by Math.sqrt(2) to try to get the low results closer to + result = max(1 - min_frechet_distance / (geo_avg_curve_len / math.sqrt(2)), 0) + return round(result, 4) \ No newline at end of file From 177041fa8f133e810dbf924bfce9c286545fd262 Mon Sep 17 00:00:00 2001 From: wenner Date: Sun, 18 Jul 2021 22:48:16 -0300 Subject: [PATCH 06/10] chore: update gitignore --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 44760db..4b3dec3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ env/ .pytest_cache/ -**/__pycache__/ \ No newline at end of file +**/__pycache__/ +example/ \ No newline at end of file From 3cf10e247b60f27cf0c90fa605805b010f047baa Mon Sep 17 00:00:00 2001 From: wenner Date: Mon, 19 Jul 2021 11:07:36 -0300 Subject: [PATCH 07/10] refactor: change in shape similarity check rotation default False --- src/shapesimilarity.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shapesimilarity.py b/src/shapesimilarity.py index bc7f061..5561379 100644 --- a/src/shapesimilarity.py +++ b/src/shapesimilarity.py @@ -3,7 +3,7 @@ from .geometry import * import math -def shape_similarity(shape1, shape2, rotations=10, checkRotation=True): +def shape_similarity(shape1, shape2, rotations=10, checkRotation=False): procrustes_normalized_curve1 = procrustes_normalize_curve(shape1) procrustes_normalized_curve2 = procrustes_normalize_curve(shape2) geo_avg_curve_len = math.sqrt( From c29ece739529cc947ea3fa3015bea08eff7dbcfa Mon Sep 17 00:00:00 2001 From: wenner Date: Mon, 19 Jul 2021 16:19:44 -0300 Subject: [PATCH 08/10] refactor: change name root --- {src => shapesimilarity}/__init__.py | 0 {src => shapesimilarity}/frechetdistance.py | 0 {src => shapesimilarity}/geometry.py | 0 {src => shapesimilarity}/procrustesanalysis.py | 0 {src => shapesimilarity}/shapesimilarity.py | 2 +- {src => shapesimilarity}/utils.py | 0 6 files changed, 1 insertion(+), 1 deletion(-) rename {src => shapesimilarity}/__init__.py (100%) rename {src => shapesimilarity}/frechetdistance.py (100%) rename {src => shapesimilarity}/geometry.py (100%) rename {src => shapesimilarity}/procrustesanalysis.py (100%) rename {src => shapesimilarity}/shapesimilarity.py (99%) rename {src => shapesimilarity}/utils.py (100%) diff --git a/src/__init__.py b/shapesimilarity/__init__.py similarity index 100% rename from src/__init__.py rename to shapesimilarity/__init__.py diff --git a/src/frechetdistance.py b/shapesimilarity/frechetdistance.py similarity index 100% rename from src/frechetdistance.py rename to shapesimilarity/frechetdistance.py diff --git a/src/geometry.py b/shapesimilarity/geometry.py similarity index 100% rename from src/geometry.py rename to shapesimilarity/geometry.py diff --git a/src/procrustesanalysis.py b/shapesimilarity/procrustesanalysis.py similarity index 100% rename from src/procrustesanalysis.py rename to shapesimilarity/procrustesanalysis.py diff --git a/src/shapesimilarity.py b/shapesimilarity/shapesimilarity.py similarity index 99% rename from src/shapesimilarity.py rename to shapesimilarity/shapesimilarity.py index 5561379..bc7f061 100644 --- a/src/shapesimilarity.py +++ b/shapesimilarity/shapesimilarity.py @@ -3,7 +3,7 @@ from .geometry import * import math -def shape_similarity(shape1, shape2, rotations=10, checkRotation=False): +def shape_similarity(shape1, shape2, rotations=10, checkRotation=True): procrustes_normalized_curve1 = procrustes_normalize_curve(shape1) procrustes_normalized_curve2 = procrustes_normalize_curve(shape2) geo_avg_curve_len = math.sqrt( diff --git a/src/utils.py b/shapesimilarity/utils.py similarity index 100% rename from src/utils.py rename to shapesimilarity/utils.py From 5a1e843c2710da0a8cc351e51d70de14381c1f71 Mon Sep 17 00:00:00 2001 From: wenner Date: Mon, 19 Jul 2021 16:20:07 -0300 Subject: [PATCH 09/10] docs: add docs --- README.md | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b7aaaa3..24d3a57 100644 --- a/README.md +++ b/README.md @@ -1 +1,63 @@ -# shape-similarity- \ No newline at end of file +

+ SHAPE SIMILARITY +

+ +## :bulb: About +The package allows you to check the similarity between two shapes/curves, using Frechet distance together with Procrustes analysis. +Internally, `shape_similarity` works by first normalizing the curves using Procrustes analysis and then calculating Fréchet distance between the curves. + +## :page_facing_up: Content +* Frechet Distance + * In mathematics, the Fréchet distance is a measure of similarity between curves that takes into account the location and ordering of the points along the curves. Imagine a person traversing a finite curved path while walking their dog on a leash, with the dog traversing a separate finite curved path. Each can vary their speed to keep slack in the leash, but neither can move backwards. The Fréchet distance between the two curves is the length of the shortest leash sufficient for both to traverse their separate paths from start to finish. Note that the definition is symmetric with respect to the two curves—the Fréchet distance would be the same if the dog were walking its owner. + +* Procrustes Analysis + * In statistics, Procrustes analysis is a form of statistical shape analysis used to analyse the distribution of a set of shapes. To compare the shapes of two or more objects, the objects must be first optimally "superimposed". Procrustes superimposition (PS) is performed by optimally translating, rotating and uniformly scaling the objects. In other words, both the placement in space and the size of the objects are freely adjusted. The aim is to obtain a similar placement and size, by minimizing a measure of shape difference called the Procrustes distance between the objects. + +## :rocket: Technologies +* [Python3](https://www.python.org/) + +## :package: Installation +1. Install with pip +```shell +$ pip3 install shapesimilarity +``` +2. Install from source +```shell +$ git clone https://github.com/nelsonwenner/shape-similarity.git + +$ pip3 install ./shape-similarity +``` + +## :information_source: Example useage +```python +from shapesimilarity import shape_similarity +import matplotlib.pyplot as plt +import numpy as np + +x = np.linspace(1, -1, num=200) + +y1 = 2*x**2 + 1 +y2 = 2*x**2 + 2 + +shape1 = np.column_stack((x, y1)) +shape2 = np.column_stack((x, y2)) + +similarity = shape_similarity(shape1, shape2) + +plt.plot(shape1[:,0], shape1[:,1], linewidth=2.0) +plt.plot(shape2[:,0], shape2[:,1], linewidth=2.0) + +plt.title(f'Shape similarity is: {similarity}', fontsize=14, fontweight='bold') +plt.show() +``` + +## :chart_with_downwards_trend: Results +![export](https://user-images.githubusercontent.com/40550247/126214358-6aa995aa-15b1-4c60-9f0e-34bbef91a99b.png) +![export](https://user-images.githubusercontent.com/40550247/126214579-302d9220-98ed-4823-992b-d4439145bc5a.png) +## :pushpin: Referencies +* [Frechet distance](https://en.wikipedia.org/wiki/Fr%C3%A9chet_distance) +* [Procrustes analysis](https://en.wikipedia.org/wiki/Procrustes_analysis) +* [Curve Matcher](https://github.com/chanind/curve-matcher) + +--- +Made with :hearts: by Nelson Wenner :wave: [Get in touch!](https://www.linkedin.com/in/nelsonwenner/) \ No newline at end of file From 73a82fb63ac2c5b6ce55e79e1a05ab1939fd7f96 Mon Sep 17 00:00:00 2001 From: wenner Date: Mon, 19 Jul 2021 16:20:28 -0300 Subject: [PATCH 10/10] refactor: update import --- tests/frechetdistance/test_frechet_distance.py | 16 ++++++++-------- tests/geometry/test_extend_point_on_line.py | 12 ++++++------ .../test_find_procrustes_rotation_angle.py | 10 +++++----- .../test_procrustes_normalize_curve.py | 4 ++-- .../test_procrustes_normalize_rotation.py | 10 +++++----- 5 files changed, 26 insertions(+), 26 deletions(-) diff --git a/tests/frechetdistance/test_frechet_distance.py b/tests/frechetdistance/test_frechet_distance.py index f34bf2b..ca27f76 100644 --- a/tests/frechetdistance/test_frechet_distance.py +++ b/tests/frechetdistance/test_frechet_distance.py @@ -1,24 +1,24 @@ +from shapesimilarity import frechet_distance import unittest -import src class TestFrechetDistance(unittest.TestCase): def test_is_zero_if_the_curves_are_the_same(self): curve1 = [[0, 0], [4, 4]] curve2 = [[0, 0], [4, 4]] - self.assertEqual(src.frechetdistance.frechet_distance(curve1, curve2), 0) - self.assertEqual(src.frechetdistance.frechet_distance(curve2, curve1), 0) + self.assertEqual(frechet_distance(curve1, curve2), 0) + self.assertEqual(frechet_distance(curve2, curve1), 0) def test_will_be_the_dist_of_the_starting_points_if_those_are_the_only_difference(self): curve1 = [[1, 0], [4, 4]] curve2 = [[0, 0], [4, 4]] - self.assertEqual(src.frechetdistance.frechet_distance(curve1, curve2), 1) - self.assertEqual(src.frechetdistance.frechet_distance(curve2, curve1), 1) + self.assertEqual(frechet_distance(curve1, curve2), 1) + self.assertEqual(frechet_distance(curve2, curve1), 1) def test_gives_correct_result_one(self): curve1 = [[1, 0], [2.4, 43], [-1, 4.3], [4, 4]] curve2 = [[0, 0], [14, 2.4], [4, 4]] self.assertEqual( - round(src.frechetdistance.frechet_distance(curve1, curve2), 4), + round(frechet_distance(curve1, curve2), 4), 39.03 ) @@ -57,11 +57,11 @@ def test_gives_correct_results_two(self): ] curve2 = [[0, 0], [14, 2.4], [4, 4]] self.assertEqual( - round(src.frechetdistance.frechet_distance(curve1, curve2), 4), + round(frechet_distance(curve1, curve2), 4), 121.54 ) def test_not_overflow_the_node_stack_if_the_curves_are_very_long(self): curve1 = [[x**0.2, x**0.2] for x in range(5000)] curve2 = [[x**0.4, x**0.4] for x in range(5000)] - self.assertEqual(src.frechetdistance.frechet_distance(curve1, curve2), 34.9) \ No newline at end of file + self.assertEqual(frechet_distance(curve1, curve2), 34.9) \ No newline at end of file diff --git a/tests/geometry/test_extend_point_on_line.py b/tests/geometry/test_extend_point_on_line.py index f7ab513..e721b0c 100644 --- a/tests/geometry/test_extend_point_on_line.py +++ b/tests/geometry/test_extend_point_on_line.py @@ -1,38 +1,38 @@ +from shapesimilarity import extend_point_on_line import unittest -import src class TestExtendPointOnLine(unittest.TestCase): def test_returns_a_point_distance_away_from_the_end_point(self): point1, point2 = [0, 0], [8, 6] self.assertEqual( - src.geometry.extend_point_on_line(point1, point2, 5), + extend_point_on_line(point1, point2, 5), [4.0, 3.0] ) def test_works_with_negative_distances(self): point1, point2 = [0, 0], [8, 6] self.assertEqual( - src.geometry.extend_point_on_line(point1, point2, -5), + extend_point_on_line(point1, point2, -5), [12.0, 9.0] ) def test_works_when_p2_is_before_p1_in_the_line(self): point1, point2 = [12, 9], [8, 6] self.assertEqual( - src.geometry.extend_point_on_line(point1, point2, 10), + extend_point_on_line(point1, point2, 10), [16.0, 12.0] ) def test_works_with_vertical_lines(self): point1, point2 = [2, 4], [2, 6] self.assertEqual( - src.geometry.extend_point_on_line(point1, point2, 7), + extend_point_on_line(point1, point2, 7), [2.0, -1.0] ) def test_works_with_vertical_lines_where_p2_is_above_p1(self): point1, point2 = [2, 6], [2, 4] self.assertEqual( - src.geometry.extend_point_on_line(point1, point2, 7), + extend_point_on_line(point1, point2, 7), [2.0, 11.0] ) \ No newline at end of file diff --git a/tests/procrustesanalysis/test_find_procrustes_rotation_angle.py b/tests/procrustesanalysis/test_find_procrustes_rotation_angle.py index b91a342..5553681 100644 --- a/tests/procrustesanalysis/test_find_procrustes_rotation_angle.py +++ b/tests/procrustesanalysis/test_find_procrustes_rotation_angle.py @@ -1,13 +1,13 @@ +from shapesimilarity import find_procrustes_rotation_angle, procrustes_normalize_curve import unittest import math -import src class TestFindProcrustesRotationAngle(unittest.TestCase): def test_determines_the_optimal_rotation_angle_to_match_2_curves_on_top_of_each_other(self): - curve1 = src.procrustesanalysis.procrustes_normalize_curve([[0, 0], [1, 0]]) - curve2 = src.procrustesanalysis.procrustes_normalize_curve([[0, 0], [0, 1]]) + curve1 = procrustes_normalize_curve([[0, 0], [1, 0]]) + curve2 = procrustes_normalize_curve([[0, 0], [0, 1]]) self.assertEqual( - src.procrustesanalysis.find_procrustes_rotation_angle(curve1, curve2), + find_procrustes_rotation_angle(curve1, curve2), (-1 * math.pi) / 2 ) @@ -15,6 +15,6 @@ def test_return_0_if_the_curves_have_the_same_rotation(self): curve1 = [[0, 0], [1, 1]] curve2 = [[0, 0], [1.5, 1.5]] self.assertEqual( - src.procrustesanalysis.find_procrustes_rotation_angle(curve1, curve2), + find_procrustes_rotation_angle(curve1, curve2), 0 ) diff --git a/tests/procrustesanalysis/test_procrustes_normalize_curve.py b/tests/procrustesanalysis/test_procrustes_normalize_curve.py index ee51f3a..29dc976 100644 --- a/tests/procrustesanalysis/test_procrustes_normalize_curve.py +++ b/tests/procrustesanalysis/test_procrustes_normalize_curve.py @@ -1,8 +1,8 @@ +from shapesimilarity import procrustes_normalize_curve import unittest -import src class TestProcrustesNormalizeCurve(unittest.TestCase): def test_normalizes_the_scale_and_translation_of_the_curve(self): curve = [[0, 0], [4, 4]] - result = src.procrustesanalysis.procrustes_normalize_curve(curve) + result = procrustes_normalize_curve(curve) self.assertEqual(result, [[-0.25, -0.25], [0.25, 0.25]]) \ No newline at end of file diff --git a/tests/procrustesanalysis/test_procrustes_normalize_rotation.py b/tests/procrustesanalysis/test_procrustes_normalize_rotation.py index db53e5c..30f428f 100644 --- a/tests/procrustesanalysis/test_procrustes_normalize_rotation.py +++ b/tests/procrustesanalysis/test_procrustes_normalize_rotation.py @@ -1,11 +1,11 @@ +from shapesimilarity import procrustes_normalize_curve, procrustes_normalize_rotation import unittest -import src class TestProcrustesNormalizeRotation(unittest.TestCase): def test_rotates_a_normalized_curve_to_match_the_rotation_of_another_normalized_curve(self): - curve = src.procrustesanalysis.procrustes_normalize_curve([[0, 0], [1, 0]]) - relative_curve = src.procrustesanalysis.procrustes_normalize_curve([[0, 0], [0, 1]]) - rotated_curve = src.procrustesanalysis.procrustes_normalize_rotation(curve, relative_curve) + curve = procrustes_normalize_curve([[0, 0], [1, 0]]) + relative_curve = procrustes_normalize_curve([[0, 0], [0, 1]]) + rotated_curve = procrustes_normalize_rotation(curve, relative_curve) self.assertEqual( list(map(lambda item: [round(item[0], 4), round(item[1], 4)], rotated_curve)), list(map(lambda item: [round(item[0], 4), round(item[1], 4)], rotated_curve)) @@ -15,5 +15,5 @@ def test_throws_an_error_if_the_curves_have_different_numbers_of_points(self): curve1 = [[0, 0], [1, 1]] curve2 = [[0, 0], [1, 1], [1.5], 1.5] with self.assertRaises(AssertionError) as context: - src.procrustesanalysis.procrustes_normalize_rotation(curve1, curve2) + procrustes_normalize_rotation(curve1, curve2) self.assertTrue('curve and relativeCurve must have the same length' in str(context.exception))