18

I would like to identify linear features, such as roads and rivers, on raster maps and convert them to a linear spatial object (SpatialLines class) using R.

The raster and sp packages can be used to convert features from rasters to polygon vector objects (SpatialPolygons class). rasterToPolygons() will extract cells of a certain value from a raster and return a polygon object. The product can be simplified using the dissolve=TRUE option, which calls routines in the rgeos package to do this.

This all works just fine, but I would prefer it to be a SpatialLines object. How can I do this?

Consider this example:

## Produce a sinuous linear feature on a raster as an example
library(raster)
r <- raster(nrow=400, ncol=400, xmn=0, ymn=0, xmx=400, ymx=400)
r[] <- NA
x <-seq(1, 100, by=0.01)
r[cellFromRowCol(r, round((sin(0.2*x) + cos(0.06*x)+2)*100), round(x*4))] <- 1

## Quick trick to make it three cells wide
r[edge(r, type="outer")] <- 1

## Plot
plot(r, legend=FALSE, axes=FALSE)

Raster representation of linear feature as an example

## Convert linear feature to a SpatialPolygons object
library(rgeos)
rPoly <- rasterToPolygons(r, fun=function(x) x==1, dissolve=TRUE)
plot(rPoly)

SpatialPolygons representation of the linear feature

Would the best approach be to find a centre line through the polygon?
Or is there existing code available to do this?

EDIT: Thanks to @mdsumner for pointing out that this is called skeletonization.

digitalmaps
  • 2,885
  • 3
  • 22
  • 28

4 Answers4

20

Here's my effort. The plan is:

  • densify the lines
  • compute a delaunay triangulation
  • take the midpoints, and take those points that are in the polygon
  • build a distance-weighted minimum spanning tree
  • find its graph diameter path

The densifying code for starters:

densify <- function(xy,n=5){
  ## densify a 2-col matrix
  cbind(dens(xy[,1],n=n),dens(xy[,2],n=n))
}

dens <- function(x,n=5){
  ## densify a vector
  out = rep(NA,1+(length(x)-1)*(n+1))
  ss = seq(1,length(out),by=(n+1))
  out[ss]=x
  for(s in 1:(length(x)-1)){
    out[(1+ss[s]):(ss[s+1]-1)]=seq(x[s],x[s+1],len=(n+2))[-c(1,n+2)]
  }
  out
}

And now the main course:

simplecentre <- function(xyP,dense){
require(deldir)
require(splancs)
require(igraph)
require(rgeos)

### optionally add extra points
if(!missing(dense)){
  xy = densify(xyP,dense)
} else {
  xy = xyP
}

### compute triangulation
d=deldir(xy[,1],xy[,2])

### find midpoints of triangle sides
mids=cbind((d$delsgs[,'x1']+d$delsgs[,'x2'])/2,
  (d$delsgs[,'y1']+d$delsgs[,'y2'])/2)

### get points that are inside the polygon 
sr = SpatialPolygons(list(Polygons(list(Polygon(xyP)),ID=1)))
ins = over(SpatialPoints(mids),sr)

### select the points
pts = mids[!is.na(ins),]

dPoly = gDistance(as(sr,"SpatialLines"),SpatialPoints(pts),byid=TRUE)
pts = pts[dPoly > max(dPoly/1.5),]

### now build a minimum spanning tree weighted on the distance
G = graph.adjacency(as.matrix(dist(pts)),weighted=TRUE,mode="upper")
T = minimum.spanning.tree(G,weighted=TRUE)

### get a diameter
path = get.diameter(T)

if(length(path)!=vcount(T)){
  stop("Path not linear - try increasing dens parameter")
}

### path should be the sequence of points in order
list(pts=pts[path+1,],tree=T)

}

Instead of the buffering of the earlier version I compute the distance from each midpoint to the line of the polygon, and only take points that are a) inside, and b) further from the edge than 1.5 of the distance of the inside point that is furthest from the edge.

Problems can arise if the polygon kinks back on itself, with long segments, and no densification. In this case the graph is a tree and the code reports it.

As a test, I digitized a line (s, SpatialLines object), buffered it (p), then computed the centreline and superimposed them:

 s = capture()
 p = gBuffer(s,width=0.2)
 plot(p,col="#cdeaff")
 plot(s,add=TRUE,lwd=3,col="red")
 scp = simplecentre(onering(p))
 lines(scp$pts,col="white")

source line (red), polygon (blue) and recovered centreline (white)

The 'onering' function just gets the coordinates of one ring from a SpatialPolygons thing that should only be one ring:

onering=function(p){p@polygons[[1]]@Polygons[[1]]@coords}

Capture spatial lines features with the 'capture' function:

capture = function(){p=locator(type="l")
            SpatialLines(list(Lines(list(Line(cbind(p$x,p$y))),ID=1)))}
Spacedman
  • 92,590
  • 12
  • 140
  • 224
  • Nice work! I ran this on a coordinate system with a much coarser resolution, and had to up the wb parameter to make it work. But when I did so, it worked beautifully. As you note, increasing wb manually works well to deal with artifacts at the line endponts. – digitalmaps Mar 10 '12 at 03:30
  • Couple of suggestions: edit to add: `else { xy = xyP }` below the `if (!missing(dense))` block, otherwise it fails when dense is not given. Also edit to remove `if(!missing(wb)){` and force it to run `gBuffer()`. If `wb` was not specified it was not buffering and finding the boundary and not centre-line. Nice one getting this out of the way _before_ the weekend. – digitalmaps Mar 10 '12 at 03:41
  • A few changes in latest edit, including replacing buffering with distance detection. – Spacedman Mar 10 '12 at 15:18
  • Getting more and more elegant every time I check back! What happened to your weekend? :) This is great, and quite efficient too. Edit: remove the `dThresh` parameter as it is not used. – digitalmaps Mar 10 '12 at 17:35
  • Testing this under various conditions. Works very well, and more than well enough for my needs (thank you!). However, it might be worth noting that it is not robust to cases where there are multiple "arms" in a linear feature (e.g. I tried this on an case where two linear features met at a crossroads where angle between was about 30 degrees. Routine found the centre-line through the length overlap, but not the "arms" of the X that these two features formed). This is how we would expect it to perform and I think the function's title `simplecentre` well conveys this limitation. – digitalmaps Mar 10 '12 at 18:25
  • 1
    Actually -- if there are multiple "arms" to a linear feature, then it is not a linear feature at all, is it! I was just thinking about cases where roads may be a feature class on a raster, and using `rasterToPolygons()` to pull this off as a polygon. So, this works with truly _linear features_ but additional strategies will be required when converting linear feature classes on rasters to `SpatialLines` objects. – digitalmaps Mar 10 '12 at 18:39
  • I think if the object has arms then the graph T in the code will be the centerlines of all the arms, and you could walk the graph. The 'diameter' function will return the longest set of connected arms as an ordered list of points... – Spacedman Mar 10 '12 at 23:58
  • 2
    This code currently fails because igraph switched from 0 indexing to 1-indexing. If you get `Error in pts[path + 1, ] : subscript out of bounds` remove the +1 from the last line of `simplecentre`. – Spacedman Nov 09 '18 at 18:33
5

Thanks to @klewis at gis.stackexchange.com for linking to this elegant algorithm for finding the centre line (in response to a related question I asked there).

The process requires finding the coordinates on the edge of a polygon describing the linear feature and performing a Voronoi tessellation of those points. The coordinates of the Voronoi tiles that fall within the polygon of the linear feature fall on the centre line. Turn these points into a line.

Voronoi tessellation is done really efficiently in R using the deldir package, and intersections of polygons and points with the rgeos package.

## Find points on boundary of rPoly (see question)
rPolyPts <-  coordinates(as(as(rPoly, "SpatialLinesDataFrame"),
                "SpatialPointsDataFrame"))

## Perform Voronoi tessellation of those points and extract coordinates of tiles
library(deldir)
rVoronoi <- tile.list(deldir(rPolyPts[, 1], rPolyPts[,2]))
rVoronoiPts <- SpatialPoints(do.call(rbind, 
                 lapply(rVoronoi, function(x) cbind(x$x, x$y))))

## Find the points on the Voronoi tiles that fall inside 
## the linear feature polygon
## N.B. That the width parameter may need to be adjusted if coordinate
## system is fractional (i.e. if longlat), but must be negative, and less
## than the dimension of a cell on the original raster.
library(rgeos)
rLinePts <- gIntersection(gBuffer(rPoly, width=-1), rVoronoiPts)

## Create SpatialLines object
rLine <- SpatialLines(list(Lines(Line(rLinePts), ID="1")))

The resulting SpatialLines object: SpatialLines object describing linear feature from a raster

Community
  • 1
  • 1
digitalmaps
  • 2,885
  • 3
  • 22
  • 28
  • I've been experimenting with solutions that use voronoi tiles, and got something that gave me a nice set of points defining the centreline. However, the problem is then to get the points in the right order, since the segments of the tiling are essentially in a random order. What looked like a proper solution actually had the line jumping back and forth a bit because the voronoi tiles were close, but not exactly, in the right order. – Spacedman Mar 09 '12 at 08:06
  • Quick test seems to confirm - the deldir code orders the vertices in increasing X coord. So if your object curves back on itself, the line points will jump around like mad. You might be able to reconstruct the lines from some of the other info returned by deldir, or it might be easier to rebuild the line from scratch using a graph algorithm. – Spacedman Mar 09 '12 at 08:42
  • @Spacedman, as I was writing some generic code to do this I realized quickly that this curve appeared to work so well because it is a near-continuous function (most linear features will not be so). However, this is an excellent point. How about finding the shortest path through the centre-line points using `shortest.paths()` from `igraph`, or possibly `shortestPath()` from `gdistance`. The challenge then would be identifying the endpoints of the linear feature. – digitalmaps Mar 09 '12 at 14:05
  • I've "un"accepted my own answer. As I think it is possible, now, to do better. – digitalmaps Mar 09 '12 at 14:11
  • Most of my attempts at this have always produced a few stray points that are hard to get rid of. The GIS packages that do this have some pretty hardcore geometry tidying code. I'll probably waste my weekend on this problem now :) – Spacedman Mar 09 '12 at 16:08
  • @Spacedman it's got me hooked too. Does GDAL have something like this that may not be hooked to R? Or what about GRASS (though farming it out seems rather inelegant). – digitalmaps Mar 09 '12 at 16:57
  • Paul, Can't you just select the Voronoi *edges* that you need rather than the vertices? Using only the vertices loses that essential topological information about how they are linked up. – whuber Mar 09 '12 at 17:29
  • @mdsumner CGAL looks fantastic. I'd be interested in this (you can reach me through the web address on my profile). – digitalmaps Mar 14 '12 at 01:19
  • I've got black triangle not line – Peter.k Jul 19 '19 at 07:41
4

You can get the boundary of that polygon as SpatialLines by direct coercion:

rLines <- as(rPoly, "SpatialLinesDataFrame")

Summarizing the coordinates down to a single "centre line" would be possible, but nothing immediate that I know of. I think that process is generally called "skeletonization":

http://en.wikipedia.org/wiki/Topological_skeleton

mdsumner
  • 29,099
  • 6
  • 83
  • 91
  • I didn't know this process was called skeletonization. This is a useful search term, thanks. However, really need that skeleton and not the boundary for features that are of irregular width. – digitalmaps Mar 07 '12 at 03:12
  • It seems that the ITK library has routines for skeletonization. Does anyone have experience interfacing this with R? http://en.wikipedia.org/wiki/Insight_Segmentation_and_Registration_Toolkit – digitalmaps Mar 07 '12 at 03:56
  • ImageJ has some skeletonization plugins, and there's an R package that talks to ImageJ. – Spacedman Mar 07 '12 at 13:26
  • I've just got the C code from here: http://tog.acm.org/resources/GraphicsGems/gemsiv/thin_image.c working called from R on rasters, but it doesn't solve the problem of vectorising the linear features. – Spacedman Mar 07 '12 at 14:03
  • 1
    @Spacedman, checked out this code, thanks. Though have little experience interfacing C code to R. Is the issue that the skeleton may have multiple "bones," and therefore how to break-off the unnecessary ones? – digitalmaps Mar 07 '12 at 21:20
0

I think ideal solution would be to build such negative buffer which dynamically reach the minimum width and doesn't break when value is too large; keeps continued object and eventually, draws a line if the value is reached. But unfortunately, this may be very compute demanding because this would be done probably in steps and checks if the value for particular point is enough to have a point (of our middle line). Possible it's ne need to have infinitive number of steps, or at least, some parametrized value.

I don't know how to implement this for now.

Peter.k
  • 1,475
  • 23
  • 40