pythonpandasopenstreetmaposmiumpyosmium

How to extract relation members from .osm xml files


All,

I've been trying to build a website (in Django) which is to be an index of all MTB routes in the world. I'm a Pythonian so wherever I can I try to use Python.

I've successfully extracted data from the OSM API (Display relation (trail) in leaflet) but found that doing this for all MTB trails (tag: route=mtb) is too much data (processing takes very long). So I tried to do everything locally by downloading a torrent of the entire OpenStreetMap dataset (from Latest Weekly Planet XML File) and filtering for tag: route=mtb using osmfilter (part of osmctools in Ubuntu 20.04), like this:

osmfilter $unzipped_osm_planet_file --keep="route=mtb" -o=$osm_planet_dir/world_mtb_routes.osm

This produces a file of about 1.2 GB and on closer inspection seems to contain all the data I need. My goal was to transform the file into a pandas.DataFrame() so I could do some further filtering en transforming before pushing relevant aspects into my Django DB. I tried to load the file as a regular XML file using Python Pandas but this crashed the Jupyter notebook Kernel. I guess the data is too big.

My second approach was this solution: How to extract and visualize data from OSM file in Python. It worked for me, at least, I can get some of the information, like the tags of the relations in the file (and the other specified details). What I'm missing is the relation members (the ways) and then the way members (the nodes) and their latitude/longitudes. I need these to achieve what I did here: Plotting OpenStreetMap relations does not generate continuous lines

I'm open to many solutions, for example one could break the file up into many different files containing 1 relation and it's members per file, using an osmium based script. Perhaps then I can move on with pandas.read_xml(). This would be nice for batch processing en filling the Database. Loading the whole OSM XML file into a pd.DataFrame would be nice but I guess this really is a lot of data. Perhaps this can also be done on a per-relation basis with pyosmium?

Any help is appreciated.


Solution

  • Ok, I figured out how to get what I want (all information per relation of the type "route=mtb" stored in an accessible way), it's a multi-step process, I'll describe it here.

    First, I downloaded the world file (went to wiki.openstreetmap.org/wiki/Planet.osm, opened the xml of the pbf file and downloaded the world file as .pbf (everything on Linux, and this file is referred to as $osm_planet_file below).

    I converted this file to o5m using osmconvert (available in Ubuntu 20.04 by doing apt install osmctools, on the Linux cli:

    osmconvert --verbose --drop-version $osm_planet_file -o=$osm_planet_dir/planet.o5m
    

    The next step is to filter all relations of interest out of this file (in my case I wanted all MTB routes: route=mtb) and store them in a new file, like this:

    osmfilter $osm_planet_dir/planet.o5m --keep="route=mtb" -o=$osm_planet_dir/world_mtb_routes.o5m
    

    This creates a much smaller file that contains all information on the relations that are MTB routes.

    From there on I switched to a Jupyter notebook and used Python3 to further divide the file into useful, manageable chunks. I first installed osmium using conda (in the env I created first but that can be skipped):

    conda install -c conda-forge osmium
    

    Then I made a recommended osm.SimpleHandle class, this class iterates through the large o5m file and while doing this it can do actions. This is the way to deal with these files because they are far to big for memory. I made the choice to iterate through the file and store everything I needed into separate json files. This does generate more than 12.000 json files but it can be done on my laptop with 8 GB of memory. This is the class:

    import osmium as osm
    import json
    import os
    
    data_dump_dir = '../data'
    
    class OSMHandler(osm.SimpleHandler):
        def __init__(self):
            osm.SimpleHandler.__init__(self)
            self.osm_data = []
    
        def tag_inventory(self, elem, elem_type):
            for tag in elem.tags:
                data = dict()
                data['version'] = elem.version,
                data['members'] = [int(member.ref) for member in elem.members if member.type == 'w'], # filter nodes from waylist => could be a mistake
                data['visible'] = elem.visible,
                data['timestamp'] = str(elem.timestamp),
                data['uid'] = elem.uid,
                data['user'] = elem.user,
                data['changeset'] = elem.changeset,
                data['num_tags'] = len(elem.tags),
                data['key'] = tag.k,
                data['value'] = tag.v,
                data['deleted'] = elem.deleted
                with open(os.path.join(data_dump_dir, str(elem.id)+'.json'), 'w') as f:
                    json.dump(data, f)
    
        def relation(self, r):
            self.tag_inventory(r, "relation")
    
    

    Run the class like this:

    osmhandler = OSMHandler()
    osmhandler.apply_file("../data/world_mtb_routes.o5m")
    

    Now we have json files with the relation number as their filename and with all metadata, and a list of the ways. But we want a list of the ways and then also all the nodes per way, so we can plot the full relations (the MTB routes). To achieve this, we parse the o5m file again (using a class build on the osm.SimpleHandler class) and this time we extract all way members (the nodes), and create a dictionary:

    class OSMHandler(osm.SimpleHandler):
        def __init__(self):
            osm.SimpleHandler.__init__(self)
            self.osm_data = dict()
    
        def tag_inventory(self, elem, elem_type):
            for tag in elem.tags:
                self.osm_data[int(elem.id)] = dict()
    #             self.osm_data[int(elem.id)]['is_closed'] = str(elem.is_closed)
                self.osm_data[int(elem.id)]['nodes'] = [str(n) for n in elem.nodes]
    
        def way(self, w):
            self.tag_inventory(w, "way")
    

    Execute the class:

    osmhandler = OSMHandler()
    osmhandler.apply_file("../data/world_mtb_routes.o5m")
    ways = osmhandler.osm_data
    

    This gives is dict (called ways) of all ways as keys and the node IDs (!Meaning we need some more steps!) as values.

    len(ways.keys())
    >>> 337597
    

    In the next (and almost last) step we add the node IDs for all ways to our relation jsons, so they become part of the files:

    all_data = dict()
    for relation_file in [
        os.path.join(data_dump_dir,file) for file in os.listdir(data_dump_dir) if file.endswith('.json')
        ]:
        with open(relation_file, 'r') as f:
            data = json.load(f)
        if 'members' in data: # Make sure these steps are never performed twice
            try:
                data['ways'] = dict()
                for way in data['members'][0]:
                    data['ways'][way] = ways[way]['nodes']
                del data['members']
                with open(relation_file, 'w') as f:
                    json.dump(data, f)
            except KeyError as err:
                print(err, relation_file) # Not sure why some relations give errors?
    

    So now we have relation jsons with all ways and all ways have all node IDs, the last thing to do is to replace the node IDs with their values (latitude and longitude). I also did this in 2 steps, first I build a nodeID:lat/lon dictionary, again using an osmium.SimpleHandler based class :

    import osmium
    
    class CounterHandler(osmium.SimpleHandler):
        def __init__(self):
            osmium.SimpleHandler.__init__(self)
            self.osm_data = dict()
    
        def node(self, n):
            self.osm_data[int(n.id)] = [n.location.lat, n.location.lon]
    

    Execute the class:

    h = CounterHandler()
    h.apply_file("../data/world_mtb_routes.o5m")
    nodes = h.osm_data
    

    This gives us dict with a latitude/longitude pair for every node ID. We can use this on our json files to fill the ways with coordinates (where there are now still only node IDs), I create these final json files in a new directory (data/with_coords in my case) because if there is an error, my original (input) json file is not affected and I can try again:

    import os
    relation_files = [file for file in os.listdir('../data/') if file.endswith('.json')]
    for relation in relation_files:
        relation_file = os.path.join('../data/',relation)
        relation_file_with_coords = os.path.join('../data/with_coords',relation)
        with open(relation_file, 'r') as f:
            data = json.load(f)
        try:
            for way in data['ways']:
                node_coords_per_way = []
                for node in data['ways'][way]:
                    node_coords_per_way.append(nodes[int(node)])
                data['ways'][way] = node_coords_per_way
            with open(relation_file_with_coords, 'w') as f:
                json.dump(data, f)
        except KeyError:
            print(relation)
    

    Now I have what I need and I can start adding the info to my Django database, but that is beyond the scope of this question.

    Btw, there are some relations that give an error, I suspect that for some relations ways were labelled as nodes but I'm not sure. I'll update here if I find out. I also have to do this process regularly (when the world file updates, or every now and then) so I'll probably write something more concise later on, but for now this works and the steps are understandable, to me, after a lot of thinking at least.

    All of the complexity comes from the fact that the data is not big enough for memory, otherwise I'd have created a pandas.DataFrame in step one and be done with it. I could also have loaded the data in a database in one go perhaps, but I'm not that good with databases, yet.