Geospatial adventures. Step 1: Shapely. | by Dmitry Selemir | Towards Data Science

This is a first in a series of posts summarising some of the key outtakes from working with geospatial data with a PropTech twist over the last couple of years. These are going to have a bit of everything — geospatial datasets, geometric shapes, raster files, maps, visualisations. Starting from the very basics and building up towards more interesting and challenging things in the later posts...
Let’s kick things off with introducing Shapely. Without a doubt one of my favourite libraries in Python — very central and absolutely essential to any geometry/geography related work you will end up doing.
The library allows you to work with three main types of geometric objects: Point, LineString and Polygons+ geometry collections if you want to combine them. There’s a bunch of others — linear rings, multi-points, multi-polygons, etc., but for now these will do, the methodologies are very much transferable.
Pretty standard installation, using pip. If, like me, you are using jupyter — you can just run
!pip install shapely
Note that GeoPandas is using Shapely under the hood, so if you have installed GeoPandas— you probably have a recent version of Shapely already. You can perform some of the Shapely operations after importing Geopandas without a separate import, however, if you want to work directly with Point and Polygon objects — you would still need to load them in first. Enough about Geopandas however, we are going to look at it in more detail in the next post.
Once installed, import it into your notebook and load the main geometry types:
import shapelyfrom shapely.geometry import Point, Polygon, LineString, GeometryCollectionimport numpy as np
I am also importing numpy because I like it so much. Seriously though, I very often find myself jumping back and forth between shapely objects and equivalent numpy arrays of coordinates as numpy allows you to do some of the operations in explicit vectorised form a lot quicker, so it’s a good idea to look at the connection between the two right from the start.
As the name suggests, this is just a point on a two-dimensional plane, characterised by a pair of coordinates.
One of the super convenient features of Shapely is — it allows you to view all the geometric objects without having to resort to any graphical package. Note that regardless of the coordinate system positioning of the object, it always centres on the object for you when you want to view it.
pt = Point(10, 10)pt1 = Point(100, 101)
You can also display the string representation of the object, by just wrapping str() around it, or convert it to a numpy array of its coordinates. As I mentioned, I find the latter particularly useful as I often find myself working with large arrays of geometric objects (for example over 6mln building polygons from OSM) and if I want to do calculations in vectorised format, numpy is absolutely irreplaceable.
There is also a method to load this string representation back into geometric format, which will come really handy when you have to load the data stored in a non-geometric format, for example from a csv file.
If you want to quickly look at several objects and see how they scale vs. each other all you have to do is turn them into a geometry collection:
A few other handy methods — distance, coordinates collection.
In[8]:
pt.distance(pt1)
Out[8]:
127.98828071350908
In[9]:
pt.x, pt.y, pt.xy
Out[9]:
(10.0, 10.0, (array('d', [10.0]), array('d', [10.0])))
One final thing before moving on to lines. All shapely objects have a .name attribute. This can be useful, for example, when you are transforming each of your polygons from a large collection stored in a GeoPandas or Pandas DataFrame into an array of smaller polygons, like a grid and want to have an easy way of relating them back to the original polygons.
In[10]:
pt.name = 'My Point'pt.name
Out[10]:
'My Point'
LineStrings are initiated in a very similar way, only this time we have a list of tuples, rather than a single one. They can cross themselves and pass through the same points multiple times, however, the latter is not recommended as it adversely impacts performance and you are better off splitting them into individual components. Note that the order of points is important as it determines the order in which you pass through them (same applies to polygons as you’ll see below).
ln = LineString([(0, 1), (20, 100), (100, 3), (120, 102), (200, 5)])
As with points, you can convert LineString object to an array of point coordinates. Order is preserved here, so this can be used to quickly get the coordinates of the first and last point — handy for constructing tree objects representing road networks for example.
In[13]:
np.array(ln)
Out[13]:
array([[  0.,   1.],       [ 20., 100.],       [100.,   3.],       [120., 102.],       [200.,   5.]])
Or if you want the same list of tuples representation used to create your LineString in the first place:
In[14]:
list(ln.coords)
Out[14]:
[(0.0, 1.0), (20.0, 100.0), (100.0, 3.0), (120.0, 102.0), (200.0, 5.0)]
You can also split out only X coordinates or only Y coordinates (of course, you can do that using numpy as well):
In[15]:
list(ln.xy[0]), list(ln.xy[-1])
Out[15]:
([0.0, 20.0, 100.0, 120.0, 200.0], [1.0, 100.0, 3.0, 102.0, 5.0])
A quick look at the graphical representation together with the points we created earlier:
Calculating a point to line distance, point projection on the line (distance from start along the line) and the length of the line is as trivial as:
In[17]:
pt.distance(ln)
Out[17]:
8.01980198019802
In[18]:
ln.project(pt), ln.length
Out[18]:
(10.801980198019802, 453.46769176178475)
In[19]:
list(ln.interpolate(ln.project(Point(1, 1))).coords)
Out[20]:
[(0.039211841976276834, 1.1940986177825703)]
Note that if the projection of the point onto the line happens to be outside of the defined area — the distance will be calculated to the nearest end of line. If you are after the actual projection — you would need to do a bit of extra geometry. The easiest thing would be to extend the first and last segments of the line and still use the same projection method.
Line intersections are pretty straightforward too, even when you end up with multiple intersections.
The result is a MultiPoint object, which you can iterate through, just as if this was a regular list, with Point objects as iterables:
In[21]:
str(ln.intersection(LineString([(0, 0), (200, 100)])))
Out[22]:
'MULTIPOINT (72.55474452554745 36.27737226277372, 110.561797752809 55.28089887640449, 144.5255474452555 72.26277372262774)'
In[22]:
[np.array(a) for a in ln.intersection(    LineString([        (0, 0),        (200, 100)    ]))]
Out[22]:
[array([72.55474453, 36.27737226]), array([110.56179775,  55.28089888]), array([144.52554745,  72.26277372])]
Not surprisingly, creating one is very similar to creating a LineString and, as in LineStrings , the order in which points are listed matters.
Polygons can have holes inside and the way these are defined follows a simple rule: Polygon([list of polygon coordinates],[list of holes]), where each hole is itself a polygon. Note that polygons representing holes have to be either fully inside your original polygon or can touch it in no more than one place.
Note that polygons can not be directly converted to a collection of points like we did with LineStrings and Points. Instead, we have to deal with their exterior and interior outlines (which are LinerRings themselves, i.e. a lineString, which loops on itself). For interior boundaries we get back an iterator:
In[25]:
np.array(poly.exterior)
Out[25]:
array([[0., 0.],       [0., 1.],       [1., 1.],       [1., 0.],       [0., 0.]])
In[26]:
[np.array(a) for a in poly.interiors]
Out[26]:
[array([[0.5, 0.5],        [0.5, 0.6],        [0.6, 0.6],        [0.6, 0.5],        [0.5, 0.5]]), array([[0.1, 0.1],        [0.1, 0.5],        [0.5, 0.5],        [0.5, 0.1],        [0.1, 0.1]])]
A few extra handy methods as a chaser:
Centroid of the polygon (or, similarly, line object)
In[27]:
np.array(poly.centroid)
Out[27]:
array([0.53795181, 0.53795181])
Two different methods to check if point is within polygon:
In[28]:
Point(0.7, 0.7).within(poly), poly.contains(Point(0.7, 0.7))
Out[28]:
(True, True)
You can easily convert LineString and point objects into Polygons by adding a buffer around them
Note — this is still a polygon (not a circle in a strict sense), comprised, in this case, of 66 points.
In[30]:
len(np.array(Point(0.7, 0.7).buffer(1).exterior))
Out[30]:
66
If you need to — you can increase the precision from the default resolution (16), by passing resolution parameter in your buffer:
In[31]:
len(np.array(Point(0.7, 0.7).buffer(1, resolution=32).exterior))
Out[31]:
130
Buffer allows you to easily relate polygons and other geometry objects. For example, if you want to cut a polygon by a line of a specific width
or cut it without any loss of surface area:
And finally, the super useful bounds metod, which can be applied both to Polygons, LineStrings and the Multi- objects, returning the coordinates of the bottom left and top right corner of the smallest bounding rectangle, aligned with the XY axis.
In[34]:
Point(10, 10).buffer(5).bounds
Out[34]:
(5.0, 5.0, 15.0, 15.0)
This is it for today. Next stop Pandas vs. GeoPandas!
other posts in this series:
Geospatial adventures. Step 2: Pandas vs. GeoPandas
Geospatial adventures. Step 3. Polygons grow on R-Trees
Geospatial adventures. Step 4. The Colour of Magic or If I Don’t See It — It Doesn’t Exist
Geospatial adventures. Step 5. Leaving the flatlands or flying over the sea of polygons