Collinearity has two aspects: angle between the segments, and proximity. You can test these aspects separately, and short-circuit the check based on either. I propose a proximity check that removes the need for an angle check entirely. The angle check below is shown for legacy reasons, since it can be useful for other reasons.
Angle
Cross products are only really defined in 3D. Dot products are defined everywhere. The angle between two vectors is defined as the arc-cosine of the dot product of their unit vectors. That applies in 2D as it does in 10D.
You can define two vectors from the origin as
a = np.array([segment_A_x2 - segment_A_x1,
segment_A_y2 - segment_A_y1])
b = np.array([segment_B_x2 - segment_B_x1,
segment_B_y2 - segment_B_y1])
a /= np.linalg.norm(a)
b /= np.linalg.norm(b)
angle = np.arccos(a.dot(b))
angle
will run between -np.pi
and np.pi
. Angles close to pi indicate anti-parallel lines, while angles close to zero indicate parallel lines. You can implement the test something like
if abs(angle) < angular_threshold or abs(angle) > np.pi - angular_threshold:
...
If you find yourself doing this computation often, you can save some cycles by skipping the arccos
entirely. As the angle goes to zero or pi, the dot product goes to +1 or -1 respectively. That means you only need to pre-compute dot_threshold = np.cos(angular_threshold)
once:
if abs(a.dot(b)) >= dot_threshold:
Proximity
To test proximity, you need to define how distance is measured. For exactly parallel lines, this is unambiguous: the two lines must be within distance_threshold
of each-other.
For not-quite-parallel lines you can do something similar: no point on one segment may be further than distance_threshold
of the line of the other segment. With this definition, you can absorb the need for a separate angle computation directly into the calculation.
See the figure below for reference:

You will have to check all four endpoints against the other lines. If you choose to, you can compute the angle from the difference of the distances, and possibly apply a scaling factor to the threshold based on the length of the line segment.
You can compute the distances using dot products as well:
def dist(p, p1, p2):
s = p2 - p1
q = p1 + (p - p1).dot(s) / s.dot(s) * s
return np.linalg.norm(p - q)
a1 = np.array([segment_A_x1, segment_A_y1])
a2 = np.array([segment_A_x2, segment_A_y2])
b1 = np.array([segment_B_x1, segment_B_y1])
b2 = np.array([segment_B_x2, segment_B_y2])
dists = np.array([[dist(a1, b1, b2), dist(b1, a1, a2)],
[dist(a2, b1, b2), dist(b2, a1, a2)]])
A more robust version of dist
is available in a utility library I made, called haggis
. You can use haggis.math.segment_distance
as follows:
dists = segment_distance([[a1, b1], [a2, b2]], # From point
[b1, a1], # Segment start
[b2, a2], # Segment end
axis=-1, # Axis containing vectors
segment=False) # Distance to entire line
The inputs broadcast together, and axis
applies to the broadcasted shape, so you don't need to repeat the endpoints twice.
The simplest version of the test is to just constrain the distance directly:
if (dists < distance_threshold).all():
...
You could account for the angle by scaling by the length of the segment. A segment that is 100x longer can deviate by 100x more from the line of the other segment and still be considered collinear-ish. In this case, you define distance_threshold
as the farthest that a unit vector can be from the line of the other vector. The number must be less than one to be meaningful:
scale = np.linalg.norm([a2 - a1, b2 - b1], axis=-1)
if (dists < distance_threshold * scale).all():
...
This version presupposes the shape we imposed on dists
in both versions of the computation, since scale
has as many elements as dists
has columns.
More complicated schemes that also account for the distance between the line segments are also possible. Such definitions of proximity are left as an exercise for the reader.