Designing A Geospatial Rest Backend Using GeoDjango
A complete module on how design a Geospatial Backend which can conduct map analysis using Django
Ever wanted to build a system where you can process map data just like Google maps or Apple maps? We are going to do exactly just that in this article 🚀. First, we would look into some basics of geographic system terminology and then dive directly into how we can do map analysis and queries.
The stack needed for this ⚙️:
- Python ^3.6
- Django ^3.1 (with GeoDjango plugin)
- Django Rest Framework
- Postgres (with
postgis
extension)
System Architecture
Our system would be used by end-users by continuously emitting its location coordinates to our REST server and those coordinates would be visible in the admin web dashboard along with some useful information attached by server.
What is GIS & Geospatial?
GIS stands for Geographic Information System and is a collection of map data with associated tools to perform operations on them. A Geospatial app on other hand, is an app which does only the operations on already available map data. It would aquire map data from third party services like Google, Apple or Mapbox etc.
SRID: Unit of GIS
- SRID stands for Spatial Reference System ID.
- There are different coordinate systems in which the shapes (which we will see soon) can be represented just as how length can be expressed in kilometres, metres etc.
- Take the example off the coordinates of Bangalore city: It can be represented as (12.9716°N, 77.5946°E) in SRID
4326
and (8637791 m, 1456487 m) in SRID900913
.
- We would use SRID 4326 which is used by GeoDjango by default and is incidentally also quite popular.
Geometric Representation
There are many standard formats of representing map data. Lets take a quick look at them:
GeoJson :
{
"type": "Feature",
"properties": {
"name": "Coors Field",
"amenity": "Baseball Stadium",
"popupContent": "This is where the Rockies play!",
},
"geometry": {
"type": "Point",
"coordinates": [ -104.99404, 39.75621]
}
}
Geometric Markup Language
<gml:Point gml:id="p21" srsName="http://www.opengis.net/def/crs/EPSG/0/4326">
<gml:coordinates>45.67, 88.56</gml:coordinates>
</gml:Point>
Well Known Text (WKT)
POINT(2 2)
LINESTRING(0 0 0,1 0 0,1 1 2)
Since we are making a REST backend, we would be using GeoJson in our HTTP payload.
Geometry Shapes
Geometry shapes makes the building block of the map data which you see in common map apps. Below are basic shapes and its GeoJson representation:
Point
A point may represent any location on earth 🌍 like a landmark, a point of interest, a city etc. It can be represented by just a pair of x and y coordinates. Its GeoJson representation would be:
{
"type": "Point",
"coordinates": [
77.601835, 12.910920
]
}
LineString
A LineString may represent a path or route 📈 on a map. It can represent a path along the road between two points or the course of a river etc. A LineString is represented by the coordinates of the first point and breakpoints in the path along with the last point.
{
"type": "LineString",
"coordinates": [
[77.60169, 12.91074],
[77.60212, 12.90945],
[77.60214, 12.90945],
[77.60215, 12.90944]
]
}
Polygon
A polygon may represent a closed space such as state boundary, a park 🏞️ etc. It is represented by the coordinates of its boundaries. For simplicity, we would consider only one single boundary. For a polygon, the first coordinate and the last coordinate should be the same as it is a closed shape. Use the following code for the desired result:
"type": "Polygon",
"coordinates": [
[
[77.60216, 12.91169],
[77.60217, 12.91167],
[77.60053, 12.91107],
[77.60225, 12.90906],
[77.60216, 12.91169]
]
]
Using GeoJson in REST API
We need GeoDjango plugin to give our Django app all of the map superpowers. GeoDjango comes built in with Django framework.
Installation
To enable it, list GeoDjango in the set of installed apps in settings.py
:
INSTALLED_APPS = [
# other installed apps...
"django.contrib.gis", # Add this
]
Note: For the postgres database, you have to install & enable the postgis extension or else GeoDjango would not work.
We will be using Django Rest Framework serializers to store our models which would have geometric fields. To parse GeoJson from the REST payload, we would execute a python module djangorestframework-gis
as shown:
pip install djangorestframework-gis
and add it to the INSTALLED_APPS in our app's settings.py
:
INSTALLED_APPS = [
# other installed apps...
"django.contrib.gis",
"rest_framework_gis", # Add this
]
Implementation
Our model and serializer would look like below:
from django.contrib.gis.db import models as gis_models
from django.db import models
from rest_framework import serializers
# Model class for factories table in database
class Factory(models.Model):
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
name = models.TextField()
geofence = gis_models.PolygonField()
class Meta:
db_table = "factories"
# Serializer class which would parse json and create
# Factory instances in database
class FactorySerializer(serializers.ModelSerializer):
class Meta:
model = Factory
fields = "__all__"
Using the serializer above the factory geofence, can render the data to be saved and retrieved by Frontend clients like below:
GEOS API
GEOS stands for Geometry Engine - Open Source and is a set of open source C++ library which implements interface for geometry types and associated operations. GeoDjango implements a high-level Python wrapper for the GEOS library. Below are some of the shapes in Django which we discussed about earlier:
from django.contrib.gis.geos import Point, Polygon, LineString
point = Point(5, 3)
linestring = LineString((0,0), (0,50), (50,50), (50,0))
polygon = Polygon( ((0.0, 0.0), (0.0, 50.0), (50.0, 50.0), (50.0, 0.0), (0.0,0.0)) )
Setting up Websocket
The Django module provides many helpful utilities to work on the map data (shapes). Before we take a look at some of them, let's set up our server to handle web sockets so that our web clients get live location update from end users.
Installation
We will be using channels
module to handle the websocket events:
pip install channels
In this article, we would just see how we can use the channels consumers to handle events. For detailed information on how to set up web sockets for Django app using channels, refer here
Implementation
Below are our consumers (or event handlers). LocationConsumer
would receive coordinates from end users and push to factory
group. FactoryConsumer
would listen on the factory
group and attach some useful information to those coordinates based on a factory geofence(Polygon) before broadcasting it to web client listeners, as executed below:
# Event handler for device location coordinates
class LocationConsumer(AsyncJsonWebsocketConsumer):
async def connect(self):
# do auth and other stuffs
self.user = userObj
# Receive message from WebSocket
async def receive_json(self, content):
if content.get("type", None) == "location_update":
latitude = content["latitude"]
longitude = content["longitude"]
await self.channel_layer.group_send(
"factory",
{
"type": "location_update",
"latitude": latitude,
"longitude": longitude,
"user_id": self.user.id,
},
)
# Event handler to broadcast locations to web clients based on a factory
# instance
class FactoryConsumer(AsyncJsonWebsocketConsumer):
groups = ["factory"]
factory = None
async def connect(self):
# We would receive the factory id from websocket url path params
if "factory_id" in self.scope["url_route"]["kwargs"]:
self.factory_id = self.scope["url_route"]["kwargs"]["factory_id"]
self.factory = await sync_to_async(Factory.objects.get)(pk=self.factory_id)
await self.accept()
async def disconnect(self, code):
pass
# Receive message from WebSocket
async def receive_json(self, content):
# Passing this since location_update function
# would get the events
async def location_update(self, event):
# Add map analysis here
# Send data to web listeners
await self.send_json(
{
"latitude": latitude,
"longitude": longitude,
"user_id": user_id
}
)
GEOS Functions
The GEOS API provides useful methods for geometric queries. Some of them are:
contains
distance
and others
.contains
This function checks if a target shape is inside the source shape. It is useful in scenarios where you want to track a device is inside a geofence. FactoryConsumer
would use this function to attach is_inside
flag to the data, as shown below:
# Event handler to broadcast locations to web clients
class FactoryConsumer(AsyncJsonWebsocketConsumer):
groups = ["factory"]
factory = None
# Other methods
async def location_update(self, event):
# Add map analysis here
latitude = event["latitude"]
longitude = event["longitude"]
user_id = event["user_id"]
point = Point(latitude, longitude, srid=4326)
is_inside = self.factory.geofence.contains(point)
# Send data to web listeners
await self.send_json(
{
"latitude": latitude,
"longitude": longitude,
"user_id": user_id,
"is_inside": is_inside
}
)
This is what a visual representation of the trust would look like:
Let's see what going on above:
- The 🔵 points are the simulated user's coordinates from an external python script which hits the server with location information after every 1 sec.
- Initially all points are rendered with blue color even if the point lie inside the factory's geofence (polygon).
- After we subscribe with the factory ID in the web socket, the server attaches the info where it shows whether the point lies inside the factory's geofence. The web client renders the points which lies inside the factory's geofence with red color 🔴.
.distance
Extending the above example, what if we want to consider a point inside if it comes within x distance of our factory's geofence. The .distance
comes handy for this scenario. This functions returns the distance of a target shape from the source shape:
# Event handler to broadcast locations to web clients
class FactoryConsumer(AsyncJsonWebsocketConsumer):
groups = ["factory"]
factory = None
# Other methods
async def connect(self):
# Extract distance from websocket url query param
query_string = self.scope["query_string"]
query_string_arr = query_string.decode("utf-8").split("=")
if query_string_arr and query_string_arr[0] == "distance":
self.distance = float(query_string_arr[1])
# Extract factory id from websocket url path param
if "factory_id" in self.scope["url_route"]["kwargs"]:
self.factory_id = self.scope["url_route"]["kwargs"]["factory_id"]
self.factory = await sync_to_async(Factory.objects.get)(pk=self.factory_id)
await self.accept()
async def location_update(self, event):
# Add map analysis here
latitude = event["latitude"]
longitude = event["longitude"]
user_id = event["user_id"]
point = Point(latitude, longitude, srid=4326)
if hasattr(self,"distance"):
is_inside = self.factory.geofence.distance(point) <= self.distance
else:
is_inside = self.factory.geofence.contains(point)
# Send data to web listeners
await self.send_json(
{
"latitude": latitude,
"longitude": longitude,
"user_id": user_id,
"is_inside": is_inside
}
)
The above code checks if the distance between the point and geofence is less than the distance specified as the query parameter in the web socket URL. If the distance query parameter is not provided, simply check if the point lies strictly inside the factory's geofence.
Visualising the above attached information for web users:
So what going on? 🤔
- At first 500 metres is given as the distance ,which will consider any point within 500 metres of our factory's geofence as inside (marked as red).
- As we increase the distance from 500m to 1000m and then to 2000m, the number of points marked as red increases as more points come inside the increased radius.
GIS ORM
GeoDjango's GIS ORM extends the Django ORM to provide spatial datatypes and lookup information from database. In simple terms, it helps us to query and store geometric datatypes in the database. The queries might include spatial queries like distance calculation, bounding box contains etc. You might probably be thinking why to use the GIS ORM when the GEOS API, as we seen above, can give you the same functionality right in our django app. Let's explore this with an example
Retrieve factories which covers a given point
Let's assume our database contains factory table which would have rows of our factories in the city 🌆. Now, we want to search the factories under which the specific equipment's location/coordinates falls. There are two approaches to solve this:
Using GEOS API:
point = Point(77.1234, 12.1234)
# Fetch all factories from database
factories = Factory.objects.all()
contained_factories = []
for factory in factories:
if factory.geofence.contains(point):
contained_factories.append(factory)
As you can infer, our code fetches all the factories info in our app and then collects the factories which encompasses the target point. This approach would require you to load the entire factory table in your app memory which is a tedious task and also the time complexity is an issue 😟 Let's see a better approach now.
Using GIS ORM:
point = Point(77.1234, 12.1234)
contained_factories = Factory.objects.filter(geofence__contains=point)
What did we do 😳? We did a lookup which calculated the factories which covers a given point right in the database and returned those factories to us. Pretty slick right? The time complexity now gets reduces to O(log(n))
😀 (faster than O(n)
). That's because the database store indexes in the B-tree which help us do queries in O(log(n))
complexity.
Intersects
We have already seen the power of contains
above ☝️. Lets check one another spatial query called intersects
:
- This lookup would return all those rows whose geometry intersects the target geometry.
- For example, we have the geofences of all states of your country in a database and we want to retrieve those states which intersects a rivers' course.
- A river's course would be a LineString.
intersects
would find those polygons (state boundaries) which intersect the given LineString (river course).
To sum it up:
Pros 💪:
GeoDjango fully supports the official GIS standards which means you get all the functionality right at your disposal.
It provides spatial ORM which help us to store/retrieve/query on our database.
It has a low learning curve if you already know Django.
It works right out of the box as it can be installed by just adding to
installed_apps
in a Django setting.
Cons 🤒:
It might prove to be an overkill if you do not need to store the geometries in a database. There are tons of Spatial modules in Python which you can use in your app. So, understanding of the requirement is the key.
It has limited support for spatial operation for MySQL. Please refer the documentation carefully.