Point-in-Polygon Testing
Is a point inside a polygon? This seemingly simple question underlies countless GIS operations: habitat analysis, demographic queries, voting district assignment, and spatial joins. This model derives the ray casting algorithm, shows why it works, and implements an efficient point-in-polygon test from first principles.
Prerequisites: ray casting, computational geometry, boolean operations
1. The Question
Is this GPS coordinate inside the national park boundary?
This fundamental spatial query appears everywhere:
- Wildlife ecology: Is this animal location inside its territory?
- Demographics: Which census tract contains this address?
- Politics: Which congressional district is this voter in?
- Emergency services: Which fire station’s coverage area is closest?
- Retail: Is this customer inside our delivery zone?
The mathematical question: Given a point $(x, y)$ and a polygon defined by vertices, is the point inside the polygon?
Complications:
- Polygons can be concave (not just rectangles)
- Polygons can have holes
- Edge cases: What if the point is exactly on the boundary?
2. The Conceptual Model
Intuitive Approach
Method: Cast a ray from the point to infinity. Count how many polygon edges the ray crosses.
Rule:
- Odd number of crossings → Point is inside
- Even number of crossings → Point is outside
Why it works:
Imagine walking from far outside the polygon toward the point:
- Each time you cross a polygon edge, you alternate between inside and outside
- If you cross an odd number of edges, you end up inside
- If you cross an even number, you end up outside
Example:
Point A: Ray crosses 1 edge → inside
Point B: Ray crosses 2 edges → outside
Point C: Ray crosses 3 edges → inside
┌──────────┐
│ A● │ ←── 1 crossing
────┼──────────┼──→
B●│ │ 2 crossings
│ C●│ ←── 3 crossings
└──────────┘
Ray Direction Choice
Simplification: Cast ray horizontally to the right (positive x-direction).
Why horizontal?
- Reduces 2D problem to 1D (only need to check y-coordinates)
- Easier to implement
- Standard convention
The test becomes: Does the horizontal ray from $(x, y)$ cross each edge?
3. Building the Mathematical Model
Edge Crossing Test
For each polygon edge from $(x_1, y_1)$ to $(x_2, y_2)$, determine if the ray from point $(x_p, y_p)$ crosses it.
Conditions for crossing:
- Y-coordinate check: Point’s y must be between edge’s y-values
(Note: Use $<$ for one endpoint to avoid double-counting shared vertices)
- X-coordinate check: Intersection point must be to the right of the point
First, find where the edge crosses the horizontal line $y = y_p$.
Line equation for edge:
\[x = x_1 + \frac{(y_p - y_1)(x_2 - x_1)}{y_2 - y_1}\]This is the x-coordinate where the edge intersects the ray’s horizontal line.
Crossing occurs if:
\[x_{\text{intersection}} > x_p\](Ray goes to the right, so intersection must be rightward of point)
Complete Algorithm
function pointInPolygon(point, polygon):
count = 0
for each edge (v1, v2) in polygon:
if (v1.y <= point.y < v2.y) or (v2.y <= point.y < v1.y):
# Edge straddles horizontal ray
x_intersect = v1.x + (point.y - v1.y) * (v2.x - v1.x) / (v2.y - v1.y)
if x_intersect > point.x:
count = count + 1
return (count % 2 == 1) # Odd = inside, Even = outside
Edge Cases
Point exactly on edge:
- Mathematically: On boundary, could define as inside or outside
- Convention: Usually treated as inside for GIS applications
- Implementation: Check distance to edge separately if exactness matters
Point on vertex:
- Same as edge case
- Usually treated as inside
Horizontal edges:
- Edge with $y_1 = y_2$ (horizontal edge)
- Our algorithm skips these (both inequalities fail)
- Correct behavior: Horizontal edges don’t affect inside/outside status
Degenerate polygons:
- Self-intersecting edges
- Zero-area polygons
- Require preprocessing or rejection
4. Worked Example by Hand
Problem: Is point $P = (3, 3)$ inside the quadrilateral with vertices:
- $A = (1, 1)$
- $B = (5, 2)$
- $C = (4, 5)$
- $D = (2, 4)$
Polygon edges: $A \to B \to C \to D \to A$
Solution
Edge 1: $A(1,1) \to B(5,2)$
Y-check: Is $1 \leq 3 < 2$? No (3 is not less than 2)
Skip this edge.
Edge 2: $B(5,2) \to C(4,5)$
Y-check: Is $2 \leq 3 < 5$? Yes
X-intersection:
\[x = 5 + \frac{(3-2)(4-5)}{5-2} = 5 + \frac{1 \times (-1)}{3} = 5 - 0.33 = 4.67\]Is $4.67 > 3$? Yes → Count = 1
Edge 3: $C(4,5) \to D(2,4)$
Y-check: Is $4 \leq 3 < 5$? No (3 is not ≥ 4)
Skip this edge.
Edge 4: $D(2,4) \to A(1,1)$
Y-check: Is $1 \leq 3 < 4$? Yes
X-intersection:
\[x = 2 + \frac{(3-4)(1-2)}{1-4} = 2 + \frac{(-1)(-1)}{-3} = 2 + \frac{1}{-3} = 2 - 0.33 = 1.67\]Is $1.67 > 3$? No (intersection is to the left)
Don’t count.
Final count: 1 (odd) → Point is INSIDE
5. Computational Implementation
Below is an interactive point-in-polygon tester.
Click on the canvas to test points
Point: (--, --)
Ray crossings: --
Result: --
Try this:
- Click inside the polygon: Ray crosses odd number of edges → Green point (INSIDE)
- Click outside: Ray crosses even number (including 0) → Red point (OUTSIDE)
- Star shape: Tests concave polygon handling
- Complex shape: Shows algorithm works for any simple polygon
- Count crossings: Orange X marks show where ray intersects edges
- Notice: Algorithm works regardless of polygon complexity
Key insight: The parity (odd/even) of crossings determines inside/outside, making the test robust for any polygon shape.
6. Interpretation
Why Ray Casting Works
Topological argument:
A polygon divides the plane into two regions: inside and outside. Any path from infinity to a point must cross the boundary an odd number of times if the point is inside, even number if outside.
Mathematical proof sketch:
- Start far outside (e.g., $x = +\infty$) → definitely outside
- Each boundary crossing toggles inside ↔ outside state
- Count crossings along path to point
- Odd count → ended inside; Even count → ended outside
This is Jordan Curve Theorem (simple closed curve divides plane into two regions).
Computational Complexity
Time complexity: $O(n)$ where $n$ is number of polygon vertices
Why: Must check every edge once
Cannot do better: Must examine every edge (counterexample: polygon could have narrow corridor)
Optimization: For many points in same polygon, precompute bounding box:
- Quick rejection: If point outside bounding box → definitely outside polygon
- Reduces unnecessary edge checks
Applications
Spatial joins:
Given 10,000 points and 500 polygons, assign each point to its containing polygon:
for each point:
for each polygon:
if pointInPolygon(point, polygon):
assign point to polygon
break
Optimization: Use spatial indexing (R-tree, quadtree) to avoid testing all polygons.
Zonal statistics:
Calculate average temperature within each climate zone:
for each temperature measurement point:
for each zone polygon:
if pointInPolygon(point, zone):
add temperature to zone's sum
for each zone:
average = sum / count
Ecological habitat analysis:
Which GPS-collared animal locations fall inside protected areas?
protected_locations = []
for each GPS fix:
if pointInPolygon(fix, park_boundary):
protected_locations.append(fix)
percent_protected = len(protected_locations) / total_fixes * 100
7. What Could Go Wrong?
Numerical Precision Issues
Floating-point arithmetic can cause problems:
# Edge from (0, 0) to (10, 10)
# Point at (5, 5.0000000001)
Is point exactly on edge? No (numerically)
Result: Might be classified as outside when visually "on" edge
Solution: Use tolerance for boundary tests:
if abs(distance_to_edge) < epsilon:
treat as "on boundary" (typically: inside)
Polygon with Holes
Standard ray casting doesn’t handle holes.
Example: Donut-shaped polygon (outer ring + inner hole)
Solution: Track polygon as multiple rings:
- Outer ring: clockwise vertices
- Holes: counter-clockwise vertices
Algorithm modification:
count = 0
for each ring in polygon:
crossings = ray_cast(point, ring)
if ring is outer:
count += crossings
else: # hole
count -= crossings
return (count % 2 == 1)
Self-Intersecting Polygons
Example: Figure-8 or bowtie shape
Ray casting still works but definition of “inside” becomes ambiguous.
Typical behavior: Uses winding number (how many times polygon winds around point).
Our algorithm: Treats each crossing independently (may not match visual intuition for complex self-intersections).
Degenerate Cases
Zero-area polygons:
- All vertices collinear → no interior
- Treat all points as outside
Duplicate vertices:
- Clean up polygon beforehand
- Remove consecutive duplicate points
Nearly-horizontal edges:
If edge has $y_1 \approx y_2$:
\[\frac{(x_2 - x_1)}{(y_2 - y_1)} \to \text{very large or undefined}\]| Solution: Skip edges with $ | y_2 - y_1 | < \varepsilon$ (effectively horizontal). |
8. Extension: Winding Number Algorithm
Alternative approach: Count signed crossings (direction matters).
Winding number = net number of times polygon winds counter-clockwise around point.
Formula:
\[w = \frac{1}{2\pi} \oint_{\partial P} d\theta\]Where $\theta$ is angle from point to polygon boundary.
Discrete version:
For each edge, calculate change in angle from point to edge:
winding_number = 0
for each edge (v1, v2):
angle1 = atan2(v1.y - point.y, v1.x - point.x)
angle2 = atan2(v2.y - point.y, v2.x - point.x)
winding_number += angle_difference(angle1, angle2)
return (winding_number != 0)
Advantages:
- Handles self-intersecting polygons better
- More geometrically meaningful
Disadvantages:
- Slower (requires trigonometric functions)
- More complex implementation
Ray casting is standard for simple polygons (faster, simpler).
9. Math Refresher: Line Intersection
Finding X-Coordinate of Intersection
Given edge from $(x_1, y_1)$ to $(x_2, y_2)$ and horizontal line $y = y_p$:
Line equation (parametric form):
\(x(t) = x_1 + t(x_2 - x_1)\) \(y(t) = y_1 + t(y_2 - y_1)\)
Where $t \in [0, 1]$ (on edge).
Find $t$ where $y(t) = y_p$:
\[y_1 + t(y_2 - y_1) = y_p\] \[t = \frac{y_p - y_1}{y_2 - y_1}\]Substitute into x equation:
\[x = x_1 + \frac{y_p - y_1}{y_2 - y_1}(x_2 - x_1)\]This is the intersection x-coordinate used in the algorithm.
Why Use $<$ Instead of $\leq$?
Edge from $(0, 0)$ to $(0, 10)$:
Two edges meet at $(0, 10)$.
If we use $\leq$ for both endpoints:
- First edge: $0 \leq y_p \leq 10$ → counts crossing at $y_p = 10$
- Second edge: $10 \leq y_p \leq 20$ → counts crossing at $y_p = 10$
Double counting! Same vertex counted twice.
Solution: Use $y_1 \leq y_p < y_2$ (include lower, exclude upper).
Result: Shared vertices counted exactly once.
Summary
- Point-in-polygon test determines if point is inside polygon boundary
- Ray casting algorithm: Cast ray to infinity, count edge crossings
- Odd crossings → inside; Even crossings → outside
- Complexity: $O(n)$ where $n$ = number of polygon vertices
- Implementation: Check y-range, calculate x-intersection, count if right of point
- Works for any simple polygon (convex or concave)
- Edge cases: Handle boundaries, holes, and degenerate polygons carefully
- Applications: Spatial joins, zonal statistics, habitat analysis, demographic queries
- Alternative: Winding number algorithm for self-intersecting polygons