1

I have an algorithm in which I need to work out the signed angle (-180 to 180) between edges in a graph. I've done some research and found plenty of specific answers but can't figure out how to relate them to my situation (e.g. this question which uses atan2, however the OP wanted only positive angles) I've tried implementing a few different ways (using atan2 or arccos) but I'm struggling to relate the examples to my specific problem. I've tried treating the edges as vectors but got strange results.

Given a graph with points (A, B, C, D, E), and the average of those points (avg)... how do I find the signed angle between one of those points (e.g. A) and the other points (e.g. B, C, D, E), taking the angle from the current origin (A) to the 'avg' point as equal to 0 degrees. Example below...

enter image description here

...in this example, the anti-clockwise angle from (A, avg) to (A, B) would be positive something (between 0 and 180), and the angle from (A, avg) to (A, E) would be negative something (between 0 and -180).

Ideally I want a formula which I could also apply to defining any of the points as the origin, for example taking point C as the origin.. the 'zero angle' would be (C, avg) and the angle between (C, avg) and (C, A) would be negative (0 to -180) and the angle between (C, avg) and (C, E) would be positive (0 to 180).

I haven't studied math beyond high-school so I find it hard to decipher equations with symbols I don't understand.

UPDATE: Thought I'd clean this up to make it more obvious what the conclusion was. I made two small changes to the accepted answer, resulting in the below snippet:

def angle(vertex, start, dest):
    AhAB = math.atan2((dest.y - vertex.y), (dest.x - vertex.x))
    AhAO = math.atan2((start.y - vertex.y), (start.x - vertex.x))
    AB = AhAB - AhAO
    # in between 0-math.pi = do nothing, more than math.pi = +(-2 * math.pi), less than zero = do nothing
    AB = math.degrees(AB + (-2 * math.pi if AB > math.pi else (2 * math.pi if AB < 0 - math.pi else 0)))
    return AB

...the final one-liner may be a bit much to grok after a few months of not working on this, so I turned it into it's own function, taking the result of AB = AhAB - AhAO as it's argument...

def calc(ab):
    if ab > math.pi:
        return ab + (-2 * math.pi)
    else:
        if ab < 0 - math.pi:
            return ab + (2 * math.pi)
        else:
            return ab + 0

I thought this was a little clearer to read, though more lines.

The final function in full:

def angle(vertex, start, dest):
    """Calculates the signed angle between two edges with the same origin. 
       Origin is the 'vertex' argument, 'start' is the bounding point of the edge to calculate the angle from.
       Positively signed result means anti-clockwise rotation about the vertex."""

    def calc_radians(ab):
        if ab > math.pi:
            return ab + (-2 * math.pi)
        else:
            if ab < 0 - math.pi:
                return ab + (2 * math.pi)
            else:
                return ab + 0

    AhAB = math.atan2((dest.y - vertex.y), (dest.x - vertex.x))
    AhAO = math.atan2((start.y - vertex.y), (start.x - vertex.x))

    res = calc_radians(AhAB - AhAO)

    return math.degrees(res)

Note: The function assumes the three arguments will all be instances of a typical Point class with x and y attributes. Also, the example graph above has only positive values, but I am fairly sure that this works with graphs that involve negative values too.

Jlanger
  • 176
  • 2
  • 14

3 Answers3

2

I read your problem statement as follows: given 2 points A and B, and a center O, find the angle A to B as the angle, positive if anticlockwise, between the vectors A→O and A→B.

If my premises are correct, then you can

  • find the angle between A→B and a horizontal, rightward line passing in A,
  • find the angle between A→O and a horizontal, rightward line passing in A,
  • find the angle A to B as the difference of said angles,
  • normalize the result range so that it's between -π and +π.

What I've said can be visualized as follows

enter image description here or in code (assuming a Point class with attributes x and y)

AhAB = math.atan2((B.y-A.y), (B.x-A.x)) # -π < AhAB ≤ +π
AhAO = math.atan2((O.y-A.y), (O.x-A.x)) # -π < AhA) ≤ +π
AB = AhAB - AhAO                        # -2π < AB ≤ +2π
AB = AB + ( 2*math.pi if AB < math.pi else (-2*math.pi if AB> math.pi else 0))

Addendum

Here it is a small code example, the position of the points is just similar to what you can see in the picture

In [18]: from math import atan2, pi
In [21]: class Point():
    ...:     def __init__(self, x, y):
    ...:         self.x, self.y = x, y
    ...:     def __repr__(self):
    ...:         return '(%s, %s)'%(self.x, self.y)
In [22]: A = Point(0.0, 0.0)
In [23]: B = Point(-2.0, 2.0)
In [24]: O = Point(0.0, -3.0)
In [25]: AhAB = atan2((B.y-A.y), (B.x-A.x)) ; print(3/4, AhAB/pi)
0.75 0.75
In [26]: AhAO = atan2((O.y-A.y), (O.x-A.x)) ; print(-1/2, AhAO/pi)
-0.5 -0.5
In [27]: AB = AhAB - AhAO ; print(5/4, AB/pi)
1.25 1.25
In [28]: AB = AB + ( 2*pi if AB < pi else (-2*pi if AB> pi else 0)) ; print(AB/pi)
-0.75
In [29]: 

The last line normalize your result AB to be in the correct range -π < AB ≤ π, adding or subtracting that doesn't change the meaning of the measured angle.

gboffi
  • 22,939
  • 8
  • 54
  • 85
  • Thanks for the diagram, that really helped! One small problem... at the end of the last line of the snippet.. "math.pi or 0)_)" the console is telling me there is a missing else statement (where the underscore is) ? – Jlanger Sep 30 '18 at 00:41
  • Ooops, I've made a syntax error (I wrote `or` where `else` was needed)! please see the corrected version that I've just uploaded. Aside: is my answer on target with your requirements? – gboffi Sep 30 '18 at 09:23
  • 1
    @asynts I created the graph using `xfig`, but to have nicely typeset maths I ① exported the graph to a combination of LaTeX code plus a PDF image ② processed the LaTeX code with a custom script to get a PDF comprising the graphical elements and the rendering of math and text ③ used `convert` from ImageMagick to have a PNG suitable for uploading `convert -density 400 arcs.pdf arcs.png` — the time consuming part is to draw the diagram in XFig, the last two parts take say 20". – gboffi Sep 30 '18 at 10:08
  • @gboffi I'm pretty sure this is on target, I'll be home later on to check it out – Jlanger Sep 30 '18 at 11:50
  • @gboffi so I've checked it out.. I changed the last line to `AB = math.degrees(AB + (-2 * math.pi if AB > math.pi else 0))` as it was producing the wrong value for cases `0 < res < math.pi`, and also for cases `res < 0`; and I wanted the result in degrees. With that change it is now returning returning anti-clockwise angles as 0 to +180 degrees, and clockwise angles as 0 to -180 degrees. I'm sorry if I didn't communicate that that was what I wanted, or if I miscommunicated my case. – Jlanger Sep 30 '18 at 18:17
  • @Jlanger No, you have been sufficiently clear, on the contrary I was rather lazy and didn't convert the results to degrees... – gboffi Sep 30 '18 at 18:19
  • @gboffi your explanation of how this problem works and the diagram both helped enormously and were just as valuable to me as the implementation in code. Thanks for your effort, and sorry again if I miscommunicated the result I wanted. I just realised that was probably that I wanted _internal_ angles positive or negative.. – Jlanger Sep 30 '18 at 18:21
  • @gboffi no worries :) – Jlanger Sep 30 '18 at 18:21
  • one more change.. another case that was returning an incorrect result for cases where `res < -math.pi`... `math.degrees(AB + (-2 * math.pi if AB > math.pi else (2 * math.pi if AB < 0 - math.pi else 0)))` – Jlanger Oct 01 '18 at 02:04
1

The definition of positive and negative angles is heavily depending on the reference system or reference point. Despite of its 'correct' definition, the basic calculation can be pretty much done based on the slope between two points and the resulting angle of incline which can be calculated by applying the inverse tan to the slope.

Applying the inverse tan in programming can be a bit annoying since many programming languages provide two different functions for this:

Both of these functions, regardless of the implementation in the math module or numpy package, return the calculated angle in radians which is basically based on the number Pi instead of degrees which makes some further conversion necessary. This can either be done manually or by applying a function like numpy.rad2deg(). To get a basic idea of the data points and to get some eye-balled estimation for the calculated results, I suggest plotting the data point by using matplotlib.

Glueing all the before-mentioned considerations into code can look like this:

import pandas as pd
import matplotlib
import numpy as np

%matplotlib inline
import matplotlib.pyplot as plt


# Define some sample data points
coords = {
'A': (1.5, 3.0),
'B': (3.0, 5.0),
'C': (5.5, 4.5),
'D': (5.8, 2.2),
'E': (2.8, 1.2)
}

# Extract data values from `coords` dict
values = np.array(list(coords.values()))

# Calculate the averaged point of all data points
avg = np.mean(values, axis=0)

# Plot sample data for better overview
for k, v in coords.items():
    plt.plot(*v, marker='o', linestyle='')
    plt.text(*v, k)
plt.plot(*avg, marker='o', linestyle='')
plt.text(*avg, 'avg')
plt.show()

# For further information about slope and angle of incline
# see Wikipedia (https://en.wikipedia.org/wiki/Slope).
# Calculating the angle from `avg` to each point. Please adopt
# to your own needs if needed for other pairs of points.

# Calculate the distance in x- and y-direction from each point to point `avg`
distances_x_y = (values - avg)

# Depending on your definition of the 'reference point' consider using
# distances_x_y = (avg - values)

# For further explanation on `atan` and `atan2` see
# https://stackoverflow.com/q/35749246/3991125 and
# https://en.wikipedia.org/wiki/Atan2 .
# Using a for loop instead of numpy's array/vectors is not very elegant,
# but easy to understand and therefore has potential for improvements.
# Calculate angle from point `avg` to each other point based on distances 
angle_radians = np.array([np.arctan2(element[1], element[0]) for element in distances_x_y])

# since `.arctan2()` or `.arctan()` return the angle in radians,
# we need to convert to degrees
angle_degrees = np.rad2deg(angle_radians)

# print results
print(angle_degrees)
albert
  • 8,027
  • 10
  • 48
  • 84
0

If you consider the coordinates x0=xavg-xA, y0=yavg-yA and x=xPoint-xA,y=yPoint-yA, the formula f(x,y) gives the signed angle that is positive as counter clockwise.

f(x,y)=pi()/2*((1+sign(x0))* (1-sign(y0^2))-(1+sign(x))* (1-sign(y^2)))

     +pi()/4*((2+sign(x0))*sign(y0)-(2+sign(x))*sign(y))

     +sign(x0*y0)*atan((abs(x0)-abs(y0))/(abs(x0)+abs(y0)))

    -sign(x*y)*atan((abs(x)-abs(y))/(abs(x)+abs(y)))
Worthwelle
  • 1,244
  • 1
  • 16
  • 19