Collecting Highway Bearings

Given a point, what highway is it on and what direction does that highway go?

I’m kicking off this blog with a geospatial example. I did some work recently where I needed to know:

  • the nearest section of highway to a given point; and,
  • the highways’s approximate bearing.

I got this working pretty well and am repeating the crux of the solution here. As an aside, while I was doing this work I came across Geoff Boeing’s beautiful post visualising similar aggregate directional information for 25 cities - definitely worth a look.

The best approach I could think of was to use OpenStreetMap data. Firstly associate a point with it’s nearest highway ‘way’ feature. Then get the coordinates of the beginning and end of the feature and use the pair of points to define a bearing. Makes sense? OK, here we go:

Get the packages

library(geosphere)
library(sf)
library(leaflet)
library(osmdata)
library(data.table)
library(ggmap)

Organise the data

To keep things manageable I’m going to use 4 points dotted around the Northern Quarter in Manchester (UK). I’ll put them in a data.frame:

df <- data.frame(ID = 1:4,
 Longitude = c(-2.232086, -2.235675, -2.233628, -2.232453),
 Latitude = c(53.484510, 53.484318, 53.484103, 53.481176), stringsAsFactors = FALSE)

Then I’ll convert to an sf (spatial features) multi point object. I use this command over and over.

df.sf <- st_as_sf(df, coords = c("Longitude", "Latitude"), crs=4326)

The crs argument is telling st_as_sf that these coordinates are WGS84 by referencing the appropriate EPSG code 4326 (it’s a good idea to memorise that number!). This is needed later when I project the points out of latitude/longitude format and into distance-from-bottom-left format. If you’re not familiar with this concept here’s a good pdf tutorial.

Visualise

At this point I’ll take a look at the 4 points. I’m using leaflet which works well with Blogdown.

m <- leaflet(df) %>% addTiles() %>% addCircleMarkers(
  lng = ~Longitude,
  lat = ~Latitude,
    radius = 8,
    color = "green",
    stroke = FALSE, fillOpacity = 0.9
  )
m

Get information from OpenStreetMap

The opq query from the osmdata package forms an OSM overpass filter and sends it to OSM’s servers. The result is an osmdata object containing various items. The item I’m interested in is the osm_lines which is a set of sf linestrings. These are the way objects representing highway segments. The query has returned 44 highway segments that intersected the bounded box of our 4 points (at the time of posting).

this.bbox <- st_bbox(df.sf)
osm.road.types = c("motorway", "motorway_link", "trunk", "trunk_link", "primary", "primary_link", "secondary", 
                   "secondary_link", "tertiary", "unclassified", "residential")
out <- opq (this.bbox) %>%
  add_osm_feature (key = "highway", value = osm.road.types) %>%
  osmdata_sf(quiet = F)
## Issuing query to Overpass API ...
## Rate limit: 2
## Query complete!
## converting OSM data to sf format
out
## Object of class 'osmdata' with:
##                  $bbox : 53.481176,-2.235675,53.48451,-2.232086
##         $overpass_call : The call submitted to the overpass API
##                  $meta : metadata including timestamp and version numbers
##            $osm_points : 'sf' Simple Features Collection with 199 points
##             $osm_lines : 'sf' Simple Features Collection with 44 linestrings
##          $osm_polygons : 'sf' Simple Features Collection with 0 polygons
##        $osm_multilines : NULL
##     $osm_multipolygons : NULL
road.lines <- out$osm_lines
nrow(road.lines)
## [1] 44

Get candidate closest roads to points

Before finding the closest highway segment to a point, I’ll narrow down the search by first identifying all the highway segments within a fixed distance of each point. This won’t make much difference here but does provide a big speed up when there’s thousands of highways to choose from. Also you can, if needed, put this code in a loop and grow or shrink the buffer distance until a suitable number of candidate highways are found. First step is to project the points and the road segments to a suitable coordinate reference system (for Manchester, the UK Ordnance Survay EPSG: 27700 is a good choice), apply a 50m buffer to the points, then identify which road segments intersect with this buffer.

df.sf.tf <- st_transform(df.sf, 27700) # OSGB 1936 British National Grid CRS
road.lines.tf <- st_transform(road.lines, 27700)
buffer.size <- 50
df.sf.tf.bf <- st_buffer(df.sf.tf, buffer.size)
intersected.items <- st_intersects(df.sf.tf.bf, road.lines.tf, sparse = F)
dim(intersected.items)
## [1]  4 44

The dimensions show I have 4 rows, one for each of the 4 points, and 44 columns, one for each OSM road segment within the bounding box. The matrix contains TRUE/FALSE values where TRUE indicates a point of intersection.

Get the closest road segment

So now, using the location of TRUE values in each row or the intersected.items matrix, I can subset the road.lines so that the nearest highway search is constrained to a much smaller number of highway segments (ones within 50m). The st_coordinates function is useful to return just the coordinate information from an sf object, and the dist2Line function from the geosphere package calculates the shortest distance between a point and a polyline (There are some good details about doing this type of calculation at the Movable Type website.

number.of.point <- nrow(intersected.items)
distances.vect <- matrix(NA_real_, nrow = nrow(df.sf.tf), ncol = 4)
colnames(distances.vect) <- c("distance", "lon", "lat", "ID")
for (i in seq_len(number.of.point)) {
  # Out of all 44 road segments, get the index of the ones that are within 50m of point i. 
  original.road.idx <- which(intersected.items[i, ] == TRUE)
  distances.vect.temp  <- dist2Line(p = st_coordinates(df.sf[i, ]), line = as_Spatial(road.lines[original.road.idx, ]))
  distances.vect.temp[1, "ID"] <- original.road.idx[distances.vect.temp[1, "ID"]]
  distances.vect[i, ] <- distances.vect.temp
}
distances.vect
##      distance       lon      lat ID
## [1,] 5.061288 -2.232137 53.48448 43
## [2,] 3.500525 -2.235712 53.48430 10
## [3,] 6.111912 -2.233558 53.48407  5
## [4,] 5.541079 -2.232491 53.48122 19

So now I’ve got the nearest road segments and the coordinates of the nearest point on the road segments to the original points. These are stored in the lon/lat columns of distances.vec. So the original vague points are now snapped to actual highway locations which is a bonus.

Get the road section bearings

Next I’ll extract the chosen 4 road segments from the full set of 44 and add the useful features I’ve just created so far. Getting the coordinates of the first and last points is a bit fiddly. I’m using data.table for this. When retrieving the coordinates via st_coordinates a third column labelled L1 is created with a group ID of each polyline object. This can be used as a grouping variable in data.table. The .SD[...] syntax holds the sub-data.table once the full data.table has been split by the grouping variable, finally 1 and .N are the first row and last row of each sub-data.table. There’s other ways to do this but the speed of data.table would pay off if I had thousands of points. Once we have the beginning and end coordinate pairs it’s straightforward to call the bearing function from the geosphere package.

out <- road.lines[as.integer(distances.vect[, "ID"]), ]
out$dist.to.nearest <- distances.vect[, "distance"]
out$snap.lat <- distances.vect[, "lat"]
out$snap.lon <- distances.vect[, "lon"]
matching.road.lines.coords <- data.table::as.data.table(st_coordinates(out))
matching.road.lines.coords.begin <- as.matrix(matching.road.lines.coords[, .SD[1, .(X, Y)], by = "L1"][, L1 := NULL])
matching.road.lines.coords.end <- as.matrix(matching.road.lines.coords[, .SD[.N, .(X, Y)], by = "L1"][, L1 := NULL])
matching.road.lines.bearing <- bearing(matching.road.lines.coords.begin, matching.road.lines.coords.end)
out$bearing <- (matching.road.lines.bearing + 360) %% 360
out$bearing
## [1] 133.2409 316.6075 217.5574 258.1974

Plot with estimated bearing

In order to visualise the estimated road bearing for my 4 points I need to add arrow endpoints information. Some trigonometry wil accomplish this (the 0.00025 value is determines the length of the direction arrow):

out$delta.x <- sin((pi*out$bearing) / 180) * 0.00025
out$delta.y <- cos((pi*out$bearing) / 180) * 0.00025

Now I can plot the snapped locations and show an arrow indicating the highway bearing over a basemap which is not only pretty but allows for a quick visual validation.

# A handcoded bounding box for tile retrieval. A function to make this would be a nice addition.
n.quarter.bb <- c(left = -2.236, bottom = 53.48, right = -2.23, top = 53.485)
n.quarter <- get_stamenmap(bbox = n.quarter.bb, zoom = 18, messaging = FALSE)
ggmap(n.quarter) + 
  geom_point(data = out, aes(x=snap.lon, y=snap.lat), colour = "darkgreen", size = 4) +
  geom_segment(data = out, aes(x=snap.lon, y=snap.lat, xend = snap.lon + delta.x, yend = snap.lat + delta.y), colour = "darkgreen", arrow = arrow(length = unit(0.2,"cm")))

Summary

The results are pretty good, definitely good enough to classify a point into one of the 8 compass bearing directions: N, NE, E… etc. In this post I’ve explored working with sf spatial points, generated and run an OSM overpass filter, and done some useful visualisation with Leaflet and ggmap. I’m sure there are things I could tidy and optimise here but I hope you found this a little useful.

comments powered by Disqus
David Leedal
David Leedal
Data scientist

My research interests include data analysis, statistics, geostatistics and data visualisation.

Related