Designing A Geospatial Rest Backend Using GeoDjango

Designing A Geospatial Rest Backend Using GeoDjango

A complete module on how design a Geospatial Backend which can conduct map analysis using Django

ยท

11 min read

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.

Architecture Diagram

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 SRID 900913.
  • 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

Point on map

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

LineString on map

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

Polygon on map

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:

geofence

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:

inside_point_infinite

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:

disance inside point demo

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 ๐Ÿ’ช:

  1. GeoDjango fully supports the official GIS standards which means you get all the functionality right at your disposal.

  2. It provides spatial ORM which help us to store/retrieve/query on our database.

  3. It has a low learning curve if you already know Django.

  4. It works right out of the box as it can be installed by just adding to installed_apps in a Django setting.

Cons ๐Ÿค’:

  1. 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.

  2. It has limited support for spatial operation for MySQL. Please refer the documentation carefully.

ย