├── graph ├── __init__.py ├── convert_graph.py ├── graph_types.py ├── graph.py ├── algorithms.py ├── graphfactory.py └── contract_graph.py ├── osm ├── __init__.py ├── osm_types.py ├── sanitize_input.py ├── read_osm.py ├── way_parser_helper.py └── xml_handler.py ├── output ├── __init__.py └── write_graph.py ├── tests ├── __init__.py ├── utils_test.py ├── integration_test.py ├── graphfactory_test.py ├── convert_graph_test.py ├── graph_test.py └── contract_graph_test.py ├── utils ├── __init__.py ├── timer.py └── geo_tools.py ├── examples ├── pycgr-to-png │ ├── requirements.txt │ ├── bremen.png │ ├── README.MD │ └── pycgr-to-png.py └── shortest-distances-drawing │ ├── requirements.txt │ ├── berlin.png │ ├── README.MD │ └── shortest-distances-drawing.py ├── pyproject.toml ├── LICENSE ├── .github └── workflows │ └── build.yml ├── .gitignore ├── configuration.py ├── run.py └── README.md /graph/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /osm/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /output/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/pycgr-to-png/requirements.txt: -------------------------------------------------------------------------------- 1 | utm>=0.5.0 2 | Pillow>=7.1.0 3 | -------------------------------------------------------------------------------- /examples/shortest-distances-drawing/requirements.txt: -------------------------------------------------------------------------------- 1 | utm>=0.5.0 2 | Pillow>=7.1.0 3 | tqdm>=4.46.0 4 | -------------------------------------------------------------------------------- /examples/pycgr-to-png/bremen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AndGem/OsmToRoadGraph/HEAD/examples/pycgr-to-png/bremen.png -------------------------------------------------------------------------------- /examples/shortest-distances-drawing/berlin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AndGem/OsmToRoadGraph/HEAD/examples/shortest-distances-drawing/berlin.png -------------------------------------------------------------------------------- /tests/utils_test.py: -------------------------------------------------------------------------------- 1 | # Automatically generated by Pynguin. 2 | import unittest 3 | 4 | import pytest 5 | 6 | import utils.geo_tools as module0 7 | 8 | 9 | class IntegrationTest(unittest.TestCase): 10 | def test_case_0(self): 11 | var0 = 180.0 12 | var1 = 2118.01088 13 | var2 = 100.0 14 | var3 = module0.distance(var0, var1, var2, var1) 15 | assert var3 == pytest.approx(8898386.658367889, abs=0.01, rel=0.01) 16 | -------------------------------------------------------------------------------- /utils/timer.py: -------------------------------------------------------------------------------- 1 | import time 2 | from typing import Callable 3 | 4 | 5 | def timer(function: Callable) -> Callable: 6 | def wrapper(*args, **kwargs): 7 | start_time = time.time() 8 | print(f"starting {function.__name__}") 9 | 10 | result = function(*args, **kwargs) 11 | 12 | print(f"finished {function.__name__} in {round(time.time() - start_time, 2)}s") 13 | print("") 14 | 15 | return result 16 | 17 | return wrapper 18 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "OsmToRoadGraph" 3 | version = "0.7.0" 4 | readme = "README.md" 5 | requires-python = ">=3.13" 6 | dependencies = [] 7 | 8 | [project.optional-dependencies] 9 | dev = ["pytest"] 10 | [tool.black] 11 | line-length = 120 12 | target-version = ['py311'] 13 | 14 | [tool.ruff.lint] 15 | select = ["I", "F", "SIM", "E", "A", "ARG", "B", "C4", "SIM", "TC", "RET", "TID", "Q"] 16 | ignore = ["E501"] 17 | fixable = ["ALL"] 18 | 19 | [dependency-groups] 20 | dev = [ 21 | "black>=25.1.0", 22 | "ruff>=0.12.5", 23 | ] 24 | -------------------------------------------------------------------------------- /graph/convert_graph.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | 3 | with contextlib.suppress(ImportError): 4 | import networkx as nx 5 | 6 | 7 | def convert_to_networkx(graph): 8 | out_graph = nx.DiGraph() 9 | 10 | for v in graph.vertices: 11 | data_dict = {s: getattr(v.data, s) for s in v.data.__slots__} 12 | out_graph.add_node(v.id, **data_dict) 13 | 14 | for e in graph.edges: 15 | data_dict = {s: getattr(e.data, s) for s in e.data.__slots__} 16 | out_graph.add_edge(e.s, e.t, **data_dict) 17 | if e.backward: 18 | out_graph.add_edge(e.t, e.s, **data_dict) 19 | 20 | return out_graph 21 | -------------------------------------------------------------------------------- /utils/geo_tools.py: -------------------------------------------------------------------------------- 1 | from math import acos, cos, pi, sin 2 | 3 | 4 | # from http://www.johndcook.com/blog/python_longitude_latitude/ 5 | def distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float: 6 | # Convert latitude and longitude to 7 | # spherical coordinates in radians. 8 | degrees_to_radians = pi / 180.0 9 | 10 | # phi = 90 - latitude 11 | phi1 = (90.0 - lat1) * degrees_to_radians 12 | phi2 = (90.0 - lat2) * degrees_to_radians 13 | 14 | # theta = longitude 15 | theta1 = lon1 * degrees_to_radians 16 | theta2 = lon2 * degrees_to_radians 17 | 18 | cos_val = sin(phi1) * sin(phi2) * cos(theta1 - theta2) + cos(phi1) * cos(phi2) 19 | cos_val = min(1, cos_val) 20 | arc_val = acos(cos_val) 21 | 22 | return arc_val * 6373000 # distance in meters 23 | -------------------------------------------------------------------------------- /osm/osm_types.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from typing import List, Optional 3 | 4 | 5 | @dataclass 6 | class OSMWay: 7 | osm_id: int 8 | nodes: List[int] = field(init=False, default_factory=list) 9 | highway: str = field(init=False, default="") 10 | area: Optional[str] = field(init=False, default=None) 11 | max_speed_str: Optional[str] = field(init=False, default=None) 12 | max_speed_int: int = field(init=False) 13 | direction: str = field(init=False, default="") 14 | forward: bool = field(init=False, default=True) 15 | backward: bool = field(init=False, default=True) 16 | name: str = field(init=False, default="") 17 | 18 | def add_node(self, osm_id: int) -> None: 19 | self.nodes.append(osm_id) 20 | 21 | 22 | @dataclass(frozen=True) 23 | class OSMNode: 24 | __slots__ = ["lat", "lon", "osm_id"] 25 | 26 | osm_id: int 27 | lat: float 28 | lon: float 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 AndGem 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build Status 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v1 12 | - name: Set up Python 3.13 13 | uses: actions/setup-python@v1 14 | with: 15 | python-version: 3.13 16 | - name: Install dependencies 17 | run: | 18 | python -m pip install --upgrade pip 19 | pip install networkx 20 | pip install black codecov flake8 mypy pytest pytest-cov 21 | - name: Lint with flake8 22 | run: | 23 | # stop the build if there are Python syntax errors or undefined names 24 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 25 | # exit-zero treats all errors as warnings. 26 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=160 --statistics 27 | - name: Install and run Black 28 | run: black --check . 29 | - name: Typechecking with mypy 30 | run: mypy . --ignore-missing-imports 31 | - name: Test with pytest 32 | run: | 33 | pytest --cov=./ --cov-report=xml 34 | codecov 35 | -------------------------------------------------------------------------------- /graph/graph_types.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | @dataclass(frozen=True) 5 | class VertexData: 6 | __slots__ = ["lat", "lon"] 7 | lat: float 8 | lon: float 9 | 10 | def __repr__(self) -> str: 11 | return f"{self.lat} {self.lon}" 12 | 13 | 14 | @dataclass(frozen=True) 15 | class Vertex: 16 | __slots__ = ["id", "data"] 17 | id: int 18 | data: VertexData 19 | 20 | @property 21 | def description(self) -> str: 22 | return f"{self.id} {self.data}" 23 | 24 | 25 | @dataclass(frozen=True) 26 | class EdgeData: 27 | __slots__ = ["length", "highway", "max_v", "name"] 28 | length: float 29 | highway: str 30 | max_v: int 31 | name: str 32 | 33 | def __repr__(self) -> str: 34 | return f"{self.length} {self.highway} {self.max_v}" 35 | 36 | 37 | @dataclass(frozen=True) 38 | class Edge: 39 | __slots__ = ["s", "t", "forward", "backward", "data"] 40 | s: int 41 | t: int 42 | forward: bool 43 | backward: bool 44 | data: EdgeData 45 | 46 | @property 47 | def description(self) -> str: 48 | both_directions = "1" if self.forward and self.backward else "0" 49 | return f"{self.s} {self.t} {self.data} {both_directions}" 50 | -------------------------------------------------------------------------------- /graph/graph.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from graph.graph_types import Edge, Vertex 4 | 5 | 6 | class Graph: 7 | def __init__(self) -> None: 8 | self.edges: List[Edge] = [] 9 | self.vertices: List[Vertex] = [] 10 | self.outneighbors: List[List[int]] = [] 11 | self.inneighbors: List[List[int]] = [] 12 | 13 | def add_edge(self, edge: Edge) -> None: 14 | self.edges.append(edge) 15 | 16 | if edge.forward: 17 | self.outneighbors[edge.s].append(edge.t) 18 | self.inneighbors[edge.t].append(edge.s) 19 | 20 | if edge.backward: 21 | self.outneighbors[edge.t].append(edge.s) 22 | self.inneighbors[edge.s].append(edge.t) 23 | 24 | def add_node(self, vertex: Vertex) -> None: 25 | self.vertices.append(vertex) 26 | self.outneighbors.append([]) 27 | self.inneighbors.append([]) 28 | 29 | def get_node(self, node_id: int) -> Vertex: 30 | return self.vertices[node_id] 31 | 32 | def edge_description(self, edge_id): 33 | return f"{self.edges[edge_id].description}" 34 | 35 | def edge_name(self, edge_id): 36 | return f"{self.edges[edge_id].name}" 37 | 38 | def all_neighbors(self, node_id: int) -> List[int]: 39 | return list(set(self.outneighbors[node_id]).union(set(self.inneighbors[node_id]))) 40 | -------------------------------------------------------------------------------- /output/write_graph.py: -------------------------------------------------------------------------------- 1 | import codecs 2 | import contextlib 3 | import json 4 | 5 | with contextlib.suppress(ImportError): 6 | import networkx as nx 7 | 8 | 9 | def write_to_file(graph, filename_base, filename_ext): 10 | filename = f"{filename_base}.{filename_ext}" 11 | print(f"writing output file: {filename}") 12 | 13 | file_header = "# Road Graph File v.0.4" 14 | header = """# number of nodes 15 | # number of edges 16 | # node_properties 17 | # ... 18 | # edge_properties 19 | # ...""" 20 | 21 | with open(filename, "w", encoding="utf-8") as f, codecs.open(f"{filename}_names", "w", "utf-8") as f_names: 22 | f.write(f"{file_header}\n") 23 | f.write(f"{header}\n") 24 | 25 | f.write(f"{len(graph.vertices)}\n") 26 | f.write(f"{len(graph.edges)}\n") 27 | 28 | # write node information 29 | for v in graph.vertices: 30 | f.write(f"{v.description}\n") 31 | 32 | # write edge information 33 | for e in graph.edges: 34 | f.write(f"{e.description}\n") 35 | f_names.write(e.data.name) 36 | f_names.write("\n") 37 | 38 | f.close() 39 | f_names.close() 40 | 41 | 42 | def write_nx_to_file(nx_graph, filename): 43 | print(f"writing networkx output file: {filename}") 44 | json_out = nx.adjacency_data(nx_graph) 45 | 46 | with open(filename, "w", encoding="utf-8") as f: 47 | json.dump(json_out, f) 48 | -------------------------------------------------------------------------------- /tests/integration_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from subprocess import call 3 | 4 | 5 | class IntegrationTest(unittest.TestCase): 6 | def test_python(self): 7 | self._execute_program() 8 | self.pedestrian_graph_ok() 9 | self.pedestrian_graph_contracted_ok() 10 | 11 | def pedestrian_graph_ok(self): 12 | nmb_nodes, nmb_edges = self._get_nmb_nodes_edges("data/karlsruhe_small.pypgr") 13 | 14 | self.assertEqual(nmb_nodes, 4108) 15 | self.assertEqual(nmb_edges, 4688) 16 | 17 | def pedestrian_graph_contracted_ok(self): 18 | nmb_nodes, nmb_edges = self._get_nmb_nodes_edges("data/karlsruhe_small.pypgrc") 19 | 20 | self.assertEqual(nmb_nodes, 1469) 21 | self.assertEqual(nmb_edges, 2039) 22 | 23 | def _execute_program(self): 24 | returncode = call( 25 | [ 26 | "python3", 27 | "run.py", 28 | "-f", 29 | "data/karlsruhe_small.osm", 30 | "-n", 31 | "p", 32 | "-c", 33 | "--networkx", 34 | ] 35 | ) 36 | self.assertEqual(0, returncode) 37 | 38 | def _get_nmb_nodes_edges(self, path): 39 | with open(path) as f: 40 | line = f.readline() 41 | while "#" in line: 42 | line = f.readline() 43 | 44 | nmb_nodes = int(line) 45 | line = f.readline() 46 | nmb_edges = int(line) 47 | return nmb_nodes, nmb_edges 48 | -------------------------------------------------------------------------------- /graph/algorithms.py: -------------------------------------------------------------------------------- 1 | from collections import deque 2 | from typing import Deque, Set 3 | 4 | from graph import graphfactory 5 | from graph.graph import Graph 6 | from utils import timer 7 | 8 | 9 | def BFS(graph: Graph, s: int) -> Set[int]: 10 | seen_nodes: Set[int] = {s} 11 | unvisited_nodes: Deque[int] = deque([s]) 12 | 13 | while unvisited_nodes: 14 | current_node = unvisited_nodes.popleft() 15 | unseen_nodes = [neighbor for neighbor in graph.all_neighbors(current_node) if neighbor not in seen_nodes] 16 | seen_nodes.update(unseen_nodes) 17 | unvisited_nodes.extend(unseen_nodes) 18 | 19 | return seen_nodes 20 | 21 | 22 | @timer.timer 23 | def computeLCC(graph): 24 | # repeatedly run BFS searches until all vertices have been reached 25 | total_nodes = set(range(len(graph.vertices))) 26 | found_nodes = [] 27 | while len(total_nodes) > 0: 28 | f_nodes = BFS(graph, total_nodes.pop()) 29 | found_nodes.append(f_nodes) 30 | total_nodes = total_nodes - f_nodes 31 | 32 | if len(found_nodes) == 0: 33 | return [] 34 | 35 | # determine largest connected components 36 | lcc = max(found_nodes, key=len) 37 | 38 | print(f"\t LCC contains {len(lcc)} nodes (removed { len(graph.vertices) - len(lcc)} nodes)") 39 | 40 | return lcc 41 | 42 | 43 | def computeLCCGraph(graph: Graph) -> Graph: 44 | lcc = computeLCC(graph) 45 | new_nodes = [graph.vertices[vertex_id] for vertex_id in lcc] 46 | return graphfactory.build_graph_from_vertices_edges(new_nodes, graph.edges) 47 | -------------------------------------------------------------------------------- /osm/sanitize_input.py: -------------------------------------------------------------------------------- 1 | from utils import timer 2 | 3 | 4 | @timer.timer 5 | def sanitize_input(ways, nodes): 6 | """ 7 | This function removes all 8 | - nodes not used in any of the Ways, and 9 | - ways that contain one or more vertices not in nodes 10 | 11 | :rtype : list of Ways, list of Vertices 12 | :param ways: list of input Ways 13 | :param nodes: list of input Vertices 14 | :return: Filtered list of Ways and Nodes 15 | """ 16 | assert isinstance(ways, list) 17 | assert isinstance(nodes, dict) 18 | 19 | def remove_adjacent_duplicates(nodes): 20 | for i in range(len(nodes) - 1, 0, -1): 21 | if nodes[i] == nodes[i - 1]: 22 | del nodes[i] 23 | 24 | ways_to_remove = [] 25 | nodes_to_remove = [] 26 | found_node_ids = set() 27 | 28 | nmb_ways = len(ways) 29 | nmb_nodes = len(nodes) 30 | 31 | # determine ways that have missing nodes 32 | for index, w in enumerate(ways): 33 | for node in w.nodes: 34 | if node not in nodes: 35 | ways_to_remove.append(index) 36 | break 37 | else: 38 | remove_adjacent_duplicates(w.nodes) 39 | found_node_ids.update(w.nodes) 40 | 41 | # remove ways 42 | for index in reversed(ways_to_remove): 43 | del ways[index] 44 | 45 | # determine nodes that do not appear in any of the ways 46 | nodes_to_remove = [osm_id for osm_id in nodes if osm_id not in found_node_ids] 47 | # remove these nodes 48 | for index in reversed(nodes_to_remove): 49 | del nodes[index] 50 | 51 | print(f"removed {nmb_nodes - len(nodes)} nodes") 52 | print(f"removed {nmb_ways - len(ways)} ways") 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # IPython Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # dotenv 79 | .env 80 | 81 | # virtualenv 82 | venv/ 83 | ENV/ 84 | 85 | # Spyder project settings 86 | .spyderproject 87 | 88 | # Rope project settings 89 | .ropeproject 90 | 91 | # Mac stuff 92 | .DS_Store 93 | 94 | 95 | data/ 96 | cover/ 97 | 98 | .mypy_cache/ 99 | .pyre/ 100 | .vscode/ 101 | 102 | # OsmToRoadGraph 103 | *.py*gr* 104 | *.bz2 105 | *.osm 106 | *.osm.json 107 | *.osm_contracted.json 108 | *.png 109 | *.dat 110 | -------------------------------------------------------------------------------- /examples/pycgr-to-png/README.MD: -------------------------------------------------------------------------------- 1 | # Road Network Visualization 2 | 3 | This project is a simple example that illustrates how to **generate a PNG image** file that shows the **road network** of any osm file. 4 | 5 | This is an example of how this can look like (the darker the higher the maximum allowed speed is on the street): 6 | ![Bremen](./bremen.png) 7 | 8 | ## How to prepare (create a pycgr file) 9 | 10 | If you already have a `*.pycgr` file you can move to `How to run`. 11 | 12 | 1. Download an OSM XML file (e.g., from here http://download.geofabrik.de/europe/germany.html you can download the [.osm.bz2] file) 13 | 1. Unzip the file to obtain the pure xml file (e.g., `bremen-latest.osm`) 14 | 1. Run `OsmToRoadGraph` by invoking `python3 run.py -f -n p -c` (e.g., `python3 run.py -f data/bremen-latest.osm -n p -c`) 15 | 1. This should have created the `*.pycgr` (e.g., `data/bremen-latest.pycgr`) 16 | 17 | ## How to run 18 | 19 | 1. Make sure you have a `*.pycgr` file (if not, see the section above) 20 | 1. Install the dependencies: `pip3 install -r requirements.txt` 21 | 1. Invoke this script as follows: 22 | 23 | ```bash 24 | python3 pycgr-to-png.py -f -o 25 | ``` 26 | 27 | For example: 28 | ```bash 29 | python3 pycgr-to-png.py -f data/bremen-latest.pycgr -o bremen.png 30 | ``` 31 | 32 | **That's it**: This should an output picture of the road network of the input OSM file. 33 | 34 | ### Further Customizations 35 | 36 | This little script offers some more customization options: 37 | ```bash 38 | Usage: pycgr-to-png.py [options] 39 | 40 | Options: 41 | -h, --help show this help message and exit 42 | -f IN_FILENAME, --file=IN_FILENAME 43 | -o OUT_FILENAME, --out=OUT_FILENAME 44 | -t TEXT, --text=TEXT text drawn in lower left corner 45 | --width=WIDTH image width in px [default=1600] 46 | --height=HEIGHT image height in px [default=1200] 47 | ``` 48 | -------------------------------------------------------------------------------- /tests/graphfactory_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import graph.graphfactory as gf 4 | from graph.graph_types import Edge, EdgeData, Vertex, VertexData 5 | 6 | 7 | class GraphfactorTest(unittest.TestCase): 8 | def test_empty_input_test(self): 9 | g = gf.build_graph_from_vertices_edges([], []) 10 | self.assertTrue(len(g.vertices) == 0) 11 | self.assertTrue(len(g.edges) == 0) 12 | 13 | def test_one_vertex_should_be_no_edge_test(self): 14 | v = Vertex(23, VertexData(1, 2)) 15 | g = gf.build_graph_from_vertices_edges([v], []) 16 | self.assertTrue(len(g.vertices) == 1) 17 | self.assertEqual(g.vertices[0].data, v.data) 18 | self.assertTrue(len(g.edges) == 0) 19 | 20 | def test_two_vertex_should_be_one_edge_test(self): 21 | v1 = Vertex(23, VertexData(1, 1)) 22 | v2 = Vertex(24, VertexData(2, 1)) 23 | e = Edge(23, 24, True, True, EdgeData(123, "", 50, "")) 24 | g = gf.build_graph_from_vertices_edges([v1, v2], [e]) 25 | self.assertTrue(len(g.vertices) == 2) 26 | self.assertTrue(len(g.edges) == 1) 27 | self.assertEqual(g.edges[0].data.length, 123) 28 | 29 | def test_two_vertex_should_fail_test(self): 30 | v1 = Vertex(23, VertexData(1, 1)) 31 | v2 = Vertex(24, VertexData(2, 1)) 32 | e = Edge(21, 24, True, True, EdgeData(123, "", 50, "")) 33 | g = gf.build_graph_from_vertices_edges([v1, v2], [e]) 34 | self.assertTrue(len(g.vertices) == 2) 35 | self.assertTrue(len(g.edges) == 0) 36 | 37 | def test_three_vertex_should_three_edges_test(self): 38 | v1 = Vertex(23, VertexData(1, 1)) 39 | v2 = Vertex(24, VertexData(2, 1)) 40 | v3 = Vertex(1, VertexData(5, 6)) 41 | e1 = Edge(v1.id, v2.id, True, True, EdgeData(123, "", 50, "")) 42 | e2 = Edge(v2.id, v3.id, True, True, EdgeData(123, "", 50, "")) 43 | e3 = Edge(v1.id, v3.id, True, True, EdgeData(123, "", 50, "")) 44 | g = gf.build_graph_from_vertices_edges([v1, v2, v3], [e1, e2, e3]) 45 | self.assertTrue(len(g.vertices) == 3) 46 | self.assertTrue(len(g.edges) == 3) 47 | -------------------------------------------------------------------------------- /osm/read_osm.py: -------------------------------------------------------------------------------- 1 | import bz2 2 | import xml.sax 3 | from typing import Dict, List, Set, Tuple 4 | 5 | from osm.osm_types import OSMNode, OSMWay 6 | from osm.way_parser_helper import WayParserHelper 7 | from osm.xml_handler import NodeHandler, PercentageFile, WayHandler 8 | from utils import timer 9 | 10 | 11 | @timer.timer 12 | def read_file(osm_filename, configuration) -> Tuple[Dict[int, OSMNode], List[OSMWay]]: 13 | parserHelper = WayParserHelper(configuration) 14 | decompressed_content = decompress_content(osm_filename) 15 | if decompressed_content: 16 | ways, found_node_ids = _read_ways(decompressed_content, parserHelper) 17 | nodes = _read_nodes(decompressed_content, found_node_ids) 18 | else: 19 | ways, found_node_ids = _read_ways(PercentageFile(osm_filename), parserHelper) 20 | nodes = _read_nodes(PercentageFile(osm_filename), found_node_ids) 21 | 22 | return nodes, ways 23 | 24 | 25 | def decompress_content(osm_filename): 26 | magic_bz2 = "\x42\x5a\x68" 27 | 28 | with open(osm_filename, "r", encoding="utf-8", errors="replace") as f: 29 | content_begin = f.read(10) 30 | 31 | if content_begin.startswith(magic_bz2): 32 | print("identified bz2 compressed file.. decompressing") 33 | with bz2.open(osm_filename, "rb") as f: 34 | content = f.read() 35 | print("done!") 36 | return content 37 | 38 | print("no compression recognized!") 39 | return None 40 | 41 | 42 | @timer.timer 43 | def _read_ways(osm_file, configuration) -> Tuple[List[OSMWay], Set[int]]: 44 | parser = xml.sax.make_parser() 45 | w_handler = WayHandler(configuration) 46 | 47 | parser.setContentHandler(w_handler) 48 | if isinstance(osm_file, PercentageFile): 49 | parser.parse(osm_file) 50 | else: 51 | xml.sax.parseString(osm_file, w_handler) 52 | 53 | return w_handler.found_ways, w_handler.found_nodes 54 | 55 | 56 | @timer.timer 57 | def _read_nodes(osm_file, found_nodes) -> Dict[int, OSMNode]: 58 | parser = xml.sax.make_parser() 59 | n_handler = NodeHandler(found_nodes) 60 | 61 | parser.setContentHandler(n_handler) 62 | if isinstance(osm_file, PercentageFile): 63 | parser.parse(osm_file) 64 | else: 65 | xml.sax.parseString(osm_file, n_handler) 66 | 67 | return n_handler.nodes 68 | -------------------------------------------------------------------------------- /configuration.py: -------------------------------------------------------------------------------- 1 | class Configuration: 2 | accepted_highways = {} 3 | accepted_highways["pedestrian"] = { 4 | "primary", 5 | "secondary", 6 | "tertiary", 7 | "unclassified", 8 | "residential", 9 | "service", 10 | "primary_link", 11 | "secondary_link", 12 | "tertiary_link", 13 | "living_street", 14 | "pedestrian", 15 | "track", 16 | "road", 17 | "footway", 18 | "steps", 19 | "path", 20 | } 21 | accepted_highways["bicycle"] = { 22 | "primary", 23 | "secondary", 24 | "tertiary", 25 | "unclassified", 26 | "residential", 27 | "service", 28 | "primary_link", 29 | "secondary_link", 30 | "tertiary_link", 31 | "living_street", 32 | "track", 33 | "road", 34 | "path", 35 | "cycleway", 36 | } 37 | accepted_highways["car"] = { 38 | "motorway", 39 | "trunk", 40 | "primary", 41 | "secondary", 42 | "tertiary", 43 | "unclassified", 44 | "residential", 45 | "service", 46 | "motorway_link", 47 | "trunk_link", 48 | "primary_link", 49 | "secondary_link", 50 | "tertiary_link", 51 | "living_street", 52 | } 53 | 54 | speed_limits = { 55 | "motorway": 120, 56 | "trunk": 120, 57 | "primary": 100, 58 | "secondary": 100, 59 | "tertiary": 70, 60 | "motorway_link": 60, 61 | "trunk_link": 60, 62 | "primary_link": 60, 63 | "secondary_link": 60, 64 | "unclassified": 50, 65 | "tertiary_link": 35, 66 | "residential": 30, 67 | "service": 10, 68 | "living_street": 5, 69 | "pedestrian": 5, 70 | "track": 5, 71 | "road": 5, 72 | "footway": 5, 73 | "steps": 5, 74 | "path": 5, 75 | "cycleway": 5, 76 | "pedestrian_indoor": 5, 77 | } 78 | 79 | walking_speed = 5 80 | max_highway_speed = 120 81 | 82 | extension = {"pedestrian": "pypgr", "car": "pycgr", "bicycle": "pybgr"} 83 | network_type = None 84 | 85 | def __init__(self, network_type="pedestrian"): 86 | self.network_type = network_type 87 | 88 | def get_file_extension(self): 89 | return self.extension[self.network_type] 90 | -------------------------------------------------------------------------------- /graph/graphfactory.py: -------------------------------------------------------------------------------- 1 | from dataclasses import replace 2 | from typing import Dict, List, Tuple 3 | 4 | from graph.graph import Graph 5 | from graph.graph_types import Edge, EdgeData, Vertex, VertexData 6 | from osm.osm_types import OSMNode, OSMWay 7 | from utils import geo_tools, timer 8 | 9 | 10 | @timer.timer 11 | def build_graph_from_osm(nodes: Dict[int, OSMNode], ways: List[OSMWay]) -> Graph: 12 | g = Graph() 13 | 14 | # 1. create mapping to 0 based index nodes 15 | node_ids = nodes.keys() 16 | id_mapper = dict(zip(node_ids, range(len(node_ids)), strict=True)) 17 | 18 | # 2. add nodes and edges 19 | _add_nodes(g, id_mapper, nodes) 20 | _add_edges(g, id_mapper, ways) 21 | 22 | return g 23 | 24 | 25 | def _add_nodes(g: Graph, id_mapper: Dict[int, int], nodes: Dict[int, OSMNode]) -> None: 26 | for n in nodes.values(): 27 | g.add_node(Vertex(id_mapper[n.osm_id], data=VertexData(n.lat, n.lon))) 28 | 29 | 30 | def _add_edges(g: Graph, id_mapper: Dict[int, int], ways: List[OSMWay]) -> None: 31 | bidirectional_edges: Dict[Tuple[int, int], int] = {} 32 | for w in ways: 33 | for i in range(len(w.nodes) - 1): 34 | s_id, t_id = id_mapper[w.nodes[i]], id_mapper[w.nodes[i + 1]] 35 | s, t = g.vertices[s_id], g.vertices[t_id] 36 | length = round(geo_tools.distance(s.data.lat, s.data.lon, t.data.lat, t.data.lon), 2) 37 | data = EdgeData(length=length, highway=w.highway, max_v=w.max_speed_int, name=w.name) 38 | edge = Edge(s_id, t_id, w.forward, w.backward, data=data) 39 | if w.forward and w.backward: 40 | smaller, bigger = min(s_id, t_id), max(s_id, t_id) 41 | if (smaller, bigger) in bidirectional_edges: 42 | print(f"found duplicated bidirectional edge {(smaller, bigger)}") 43 | print(f"(osm ids {w.osm_id} and {bidirectional_edges[(smaller, bigger)]})... skipping one") 44 | continue 45 | bidirectional_edges[(smaller, bigger)] = w.osm_id 46 | 47 | g.add_edge(edge) 48 | 49 | 50 | @timer.timer 51 | def build_graph_from_vertices_edges(vertices: List[Vertex], edges: List[Edge]) -> Graph: 52 | g = Graph() 53 | 54 | # 1. add all nodes and create mapping to 0 based index nodes 55 | vertex_ids = {v.id for v in vertices} 56 | id_mapper = dict(zip(vertex_ids, range(len(vertex_ids)), strict=True)) 57 | for v in vertices: 58 | g.add_node(Vertex(id_mapper[v.id], v.data)) 59 | 60 | # 2. create edges with proper node ids 61 | valid_edges = [e for e in edges if e.s in vertex_ids and e.t in vertex_ids] 62 | new_edges = [replace(e, s=id_mapper[e.s], t=id_mapper[e.t]) for e in valid_edges] 63 | 64 | # 3. add those new edges to the graph 65 | for e in new_edges: 66 | g.add_edge(e) 67 | 68 | return g 69 | -------------------------------------------------------------------------------- /examples/shortest-distances-drawing/README.MD: -------------------------------------------------------------------------------- 1 | # Shortest Distances Drawing 2 | 3 | This project is a simple example that illustrates how to **generate a PNG image** file that shows the **shortest distances** from a random/central node of a road network to all other nodes. 4 | Below you see an example of how this can look like. The darker the color, the closer it is to the starting node of the shortest path tree: 5 | ![Berlin](./berlin.png) 6 | 7 | This example also shows how to use the networkx output of [OsmToRoadGraph](../../../). 8 | 9 | ## How to prepare (create a networkx json file) 10 | 11 | If you already have a `*.json` file you can move to `How to run`. 12 | 13 | 1. Download an OSM XML file (e.g., from here http://download.geofabrik.de/europe/germany.html you can download the [.osm.bz2] file) 14 | 1. Unzip the file to obtain the pure xml file (e.g., `bremen-latest.osm`) 15 | 1. Run `OsmToRoadGraph` by invoking `python3 run.py -f -n p -c --networkx` (e.g., `python3 run.py -f data/bremen-latest.osm -n p -c --networkx`) 16 | 1. This should have created the `*.json` (e.g., `data/bremen-latest.json`) 17 | 18 | ## How to run 19 | 20 | 1. Make sure you have a `*.json` file (if not, see the section above) 21 | 1. Install the dependencies: `pip3 install -r requirements.txt` 22 | 1. Invoke this script as follows: 23 | 24 | ```bash 25 | python3 shortest-distances-drawing.py -f -o 26 | ``` 27 | 28 | For example: 29 | 30 | ```bash 31 | python3 shortest-distances-drawing.py -f data/berlin-latest.json -o berlin.png 32 | ``` 33 | 34 | **That's it**: This should an output picture of the shortest distances of road network of the input OSM file. 35 | 36 | ### Further Customizations 37 | 38 | This little script offers some more customization options: 39 | 40 | ```bash 41 | Options: 42 | -h, --help show this help message and exit 43 | -f IN_FILENAME, --file=IN_FILENAME 44 | input networkx JSON file 45 | -o OUT_FILENAME, --out=OUT_FILENAME 46 | output png file 47 | -c, --center set this option to compute the shortest distances from 48 | an approximate center node [default: random node] 49 | -m METRIC, --metric=METRIC 50 | metric for the shortest path algorithm. Either 51 | 'length' or 'travel-time' [default: travel-time] 52 | --width=WIDTH image width in px [default=1600] 53 | --height=HEIGHT image height in px [default=1200] 54 | ``` 55 | 56 | ## Make it faster 57 | 58 | If you want to run the code faster you can also use `pypy3`: 59 | 60 | 1. Get pypy3 https://www.pypy.org/index.html 61 | 2. Bootstrap pip `pypy3 -m ensurepip` 62 | 3. Install dependencies: `pypy3 -m pip install -r requirements.txt` 63 | 4. Run it `pypy3 shortest-distances-drawing.py -f -o ` 64 | -------------------------------------------------------------------------------- /tests/convert_graph_test.py: -------------------------------------------------------------------------------- 1 | import random 2 | import string 3 | import unittest 4 | 5 | from graph.convert_graph import convert_to_networkx 6 | from graph.graph import Graph 7 | from graph.graph_types import Edge, EdgeData, Vertex, VertexData 8 | 9 | 10 | def random_string(length=8): 11 | return "".join(random.choice(string.ascii_lowercase) for i in range(length)) 12 | 13 | 14 | def random_uint(max_val=1000): 15 | return random.randint(1, max_val) 16 | 17 | 18 | class TestConvertGraphTest(unittest.TestCase): 19 | def test_converting_K4_graph(self): 20 | g = self.create_graph(number_nodes=4) 21 | 22 | g.add_edge(Edge(s=0, t=1, forward=True, backward=True, data=self.edge_data())) 23 | g.add_edge(Edge(s=1, t=2, forward=True, backward=True, data=self.edge_data())) 24 | g.add_edge(Edge(s=2, t=3, forward=True, backward=True, data=self.edge_data())) 25 | g.add_edge(Edge(s=3, t=0, forward=True, backward=True, data=self.edge_data())) 26 | 27 | g.add_edge(Edge(s=1, t=3, forward=True, backward=True, data=self.edge_data())) 28 | g.add_edge(Edge(s=0, t=2, forward=True, backward=True, data=self.edge_data())) 29 | 30 | nx_graph = convert_to_networkx(g) 31 | 32 | self.assertEqual(len(nx_graph.nodes), 4) 33 | self.assertEqual(len(nx_graph.edges), 12) 34 | 35 | def test_if_data_is_converted(self): 36 | g = self.create_graph(number_nodes=2) 37 | 38 | edge_data = self.edge_data() 39 | g.add_edge(Edge(s=0, t=1, forward=True, backward=True, data=edge_data)) 40 | 41 | nx_graph = convert_to_networkx(g) 42 | 43 | self.assertEqual(len(nx_graph.nodes), 2) 44 | self.assertEqual(len(nx_graph.edges), 2) 45 | 46 | nx_edges = nx_graph.edges(data=True) 47 | e0, e1 = nx_edges 48 | self.assertCountEqual([(e0[0], e0[1]), (e1[0], e1[1])], [(0, 1), (1, 0)]) 49 | self.assertDictEqual( 50 | e0[2], 51 | { 52 | "highway": edge_data.highway, 53 | "length": edge_data.length, 54 | "max_v": edge_data.max_v, 55 | "name": edge_data.name, 56 | }, 57 | ) 58 | self.assertDictEqual( 59 | e1[2], 60 | { 61 | "highway": edge_data.highway, 62 | "length": edge_data.length, 63 | "max_v": edge_data.max_v, 64 | "name": edge_data.name, 65 | }, 66 | ) 67 | 68 | def create_graph(self, number_nodes): 69 | g = Graph() 70 | for i in range(number_nodes): 71 | g.add_node(self.vertex(i)) 72 | return g 73 | 74 | def edge_data(self): 75 | length = random_uint() 76 | highway = random_string() 77 | max_v = random_uint() 78 | name = random_string() 79 | 80 | return EdgeData(length=length, highway=highway, max_v=max_v, name=name) 81 | 82 | def vertex(self, index): 83 | return Vertex(index, VertexData(0, 0)) 84 | -------------------------------------------------------------------------------- /osm/way_parser_helper.py: -------------------------------------------------------------------------------- 1 | from osm.osm_types import OSMWay 2 | 3 | 4 | class WayParserHelper: 5 | ONEWAY_STR = "oneway" 6 | MPH_STRINGS = ["mph", "mp/h"] 7 | KMH_STRINGS = ["kph", "kp/h", "kmh", "km/h"] 8 | WALK_STR = "walk" 9 | NONE_STR = "none" 10 | SIGNALS_STR = "signals" 11 | 12 | def __init__(self, config): 13 | self.config = config 14 | 15 | def is_way_acceptable(self, way: OSMWay): 16 | # reject: if the way marks the border of an area 17 | if way.area == "yes": 18 | return False 19 | 20 | if way.highway not in self.config.accepted_highways[self.config.network_type]: 21 | return False 22 | 23 | return True 24 | 25 | def parse_direction(self, way): 26 | if way.direction == self.ONEWAY_STR: 27 | return True, False 28 | 29 | return True, True 30 | 31 | def convert_str_to_number(self, s): 32 | # remove all non-digts except ',' and '.' 33 | cleaned_up = "".join(filter(lambda x: x.isdigit() or x == "," or x == ".", s)) 34 | # remove everything after first '.' or ',' 35 | if "." in cleaned_up: 36 | cleaned_up = cleaned_up.split(".", maxsplit=1)[0] 37 | if "," in cleaned_up: 38 | cleaned_up = cleaned_up.split(",", maxsplit=1)[0] 39 | return cleaned_up 40 | 41 | def parse_max_speed(self, osm_way: OSMWay) -> int: 42 | maximum_speed = osm_way.max_speed_str 43 | highway = osm_way.highway 44 | 45 | if maximum_speed is None: 46 | return self.config.speed_limits[highway] 47 | 48 | try: 49 | return int(maximum_speed) 50 | 51 | except ValueError: 52 | max_speed_str = maximum_speed.lower() 53 | 54 | if self.WALK_STR in max_speed_str: 55 | max_speed = self.config.walking_speed 56 | elif self.NONE_STR in max_speed_str: 57 | max_speed = self.config.max_highway_speed 58 | elif any(s in max_speed_str for s in self.MPH_STRINGS): 59 | max_speed_kmh_str = self.convert_str_to_number(max_speed_str) 60 | max_speed = int(float(max_speed_kmh_str) * 1.609344) 61 | elif any(s in max_speed_str for s in self.KMH_STRINGS): 62 | max_speed = int(self.convert_str_to_number(max_speed_str)) 63 | else: 64 | if self.SIGNALS_STR in max_speed_str: 65 | # according to https://wiki.openstreetmap.org/wiki/Key:maxspeed 'signals' indicates 66 | # that the max speed is shown by some sort of signalling. Here, we fallback to the default of the highway type. 67 | pass 68 | else: 69 | print( 70 | f"error while parsing max speed of osm way {osm_way.osm_id}! Did not recognize: {max_speed_str}" 71 | ) 72 | print("fallback by setting it to default value") 73 | 74 | if highway in self.config.speed_limits: 75 | max_speed = self.config.speed_limits[highway] 76 | else: 77 | max_speed = 30 78 | print(f"couldn't find a speed limit for highway type {highway}! Setting it to {max_speed}") 79 | 80 | return max_speed 81 | -------------------------------------------------------------------------------- /tests/graph_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from graph.graph import Graph 4 | from graph.graph_types import Edge, EdgeData, Vertex, VertexData 5 | 6 | 7 | class GraphTest(unittest.TestCase): 8 | def test_add_edge_adds_one_edge_test(self): 9 | g = Graph() 10 | 11 | v1, v2 = self._get_vertex(0), self._get_vertex(1) 12 | data = EdgeData(23.4, "", 100, "") 13 | e = Edge(v1.id, v2.id, True, True, data) 14 | 15 | g.add_node(v1) 16 | g.add_node(v2) 17 | g.add_edge(e) 18 | 19 | self.assertEqual(len(g.edges), 1) 20 | self.assertEqual(g.edges[0], e) 21 | 22 | def test_add_edges_correct_in_out_neighbors_test(self): 23 | g = Graph() 24 | 25 | v1, v2, v3, v4 = ( 26 | self._get_vertex(0), 27 | self._get_vertex(1), 28 | self._get_vertex(2), 29 | self._get_vertex(3), 30 | ) 31 | e_forward = Edge(v1.id, v2.id, True, False, EdgeData(1, " ", 100, "Test")) 32 | e_backward = Edge(v2.id, v3.id, False, True, EdgeData(1, " ", 100, "Test")) 33 | e_nothing = Edge(v3.id, v4.id, False, False, EdgeData(1, " ", 100, "Test")) 34 | e_both = Edge(v4.id, v1.id, True, True, EdgeData(1, " ", 100, "Test")) 35 | 36 | g.add_node(v1) 37 | g.add_node(v2) 38 | g.add_node(v3) 39 | g.add_node(v4) 40 | 41 | g.add_edge(e_forward) 42 | g.add_edge(e_backward) 43 | g.add_edge(e_nothing) 44 | g.add_edge(e_both) 45 | 46 | self.assertEqual(len(g.edges), 4) 47 | self.assertEqual(g.edges[0], e_forward) 48 | self.assertEqual(g.edges[1], e_backward) 49 | self.assertEqual(g.edges[2], e_nothing) 50 | self.assertEqual(g.edges[3], e_both) 51 | 52 | self.assertIn(e_forward.s, g.inneighbors[e_forward.t]) 53 | self.assertIn(e_forward.t, g.outneighbors[e_forward.s]) 54 | self.assertNotIn(e_forward.s, g.outneighbors[e_forward.t]) 55 | self.assertNotIn(e_forward.t, g.inneighbors[e_forward.s]) 56 | 57 | self.assertNotIn(e_backward.s, g.inneighbors[e_backward.t]) 58 | self.assertNotIn(e_backward.t, g.outneighbors[e_backward.s]) 59 | self.assertIn(e_backward.s, g.outneighbors[e_backward.t]) 60 | self.assertIn(e_backward.t, g.inneighbors[e_backward.s]) 61 | 62 | self.assertNotIn(e_nothing.s, g.inneighbors[e_nothing.t]) 63 | self.assertNotIn(e_nothing.t, g.outneighbors[e_nothing.s]) 64 | self.assertNotIn(e_nothing.s, g.outneighbors[e_nothing.t]) 65 | self.assertNotIn(e_nothing.t, g.inneighbors[e_nothing.s]) 66 | 67 | self.assertIn(e_both.s, g.inneighbors[e_both.t]) 68 | self.assertIn(e_both.t, g.outneighbors[e_both.s]) 69 | self.assertIn(e_both.s, g.outneighbors[e_both.t]) 70 | self.assertIn(e_both.t, g.inneighbors[e_both.s]) 71 | 72 | def test_add_edges_correct_set_of_neighbors_test(self): 73 | g = Graph() 74 | v1, v2, v3 = self._get_vertex(0), self._get_vertex(1), self._get_vertex(2) 75 | e_forward = Edge(v1.id, v2.id, True, False, EdgeData(1, " ", 100, "Test")) 76 | e_backward = Edge(v2.id, v3.id, False, True, EdgeData(1, " ", 100, "Test")) 77 | e_both = Edge(v3.id, v1.id, True, True, EdgeData(1, " ", 100, "Test")) 78 | 79 | g.add_node(v1) 80 | g.add_node(v2) 81 | g.add_node(v3) 82 | 83 | g.add_edge(e_forward) 84 | g.add_edge(e_backward) 85 | g.add_edge(e_both) 86 | 87 | self.assertTrue(self._checkEqual([v2.id, v3.id], g.all_neighbors(v1.id))) 88 | self.assertTrue(self._checkEqual([v1.id, v3.id], g.all_neighbors(v2.id))) 89 | self.assertTrue(self._checkEqual([v1.id, v2.id], g.all_neighbors(v3.id))) 90 | 91 | def _checkEqual(self, L1, L2): 92 | return len(L1) == len(L2) and sorted(L1) == sorted(L2) 93 | 94 | def _get_vertex(self, index=0): 95 | return Vertex(index, VertexData(1.2, 2.3)) 96 | -------------------------------------------------------------------------------- /run.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import importlib.util 3 | import os 4 | import sys 5 | 6 | import configuration as config 7 | from graph import algorithms, contract_graph, convert_graph, graphfactory 8 | from osm import read_osm, sanitize_input 9 | from output import write_graph as output 10 | from utils import timer 11 | 12 | 13 | @timer.timer 14 | def convert_osm_to_roadgraph(filename, network_type, options): 15 | configuration = config.Configuration(network_type) 16 | 17 | out_file, _ = os.path.splitext(filename) 18 | 19 | print(f"selected network type: {configuration.network_type}") 20 | print(f"accepted highway tags: {configuration.accepted_highways}") 21 | print(f"opening file: {filename}") 22 | 23 | try: 24 | nodes, ways = read_osm.read_file(filename, configuration) 25 | except Exception as e: 26 | print(f"Error occurred while reading file {filename}: {e}") 27 | return 28 | 29 | sanitize_input.sanitize_input(ways, nodes) 30 | 31 | graph = graphfactory.build_graph_from_osm(nodes, ways) 32 | 33 | if not options.lcc: 34 | graph = algorithms.computeLCCGraph(graph) 35 | 36 | output.write_to_file(graph, out_file, configuration.get_file_extension()) 37 | 38 | if options.networkx_output: 39 | validate_networkx() 40 | nx_graph = convert_graph.convert_to_networkx(graph) 41 | output.write_nx_to_file(nx_graph, f"{out_file}.json") 42 | 43 | if options.contract: 44 | contracted_graph = contract_graph.ContractGraph(graph).contract() 45 | output.write_to_file(contracted_graph, out_file, f"{configuration.get_file_extension()}c") 46 | if options.networkx_output: 47 | nx_graph = convert_graph.convert_to_networkx(contracted_graph) 48 | output.write_nx_to_file(nx_graph, f"{out_file}_contracted.json") 49 | 50 | 51 | def validate_networkx(): 52 | networkx_spec = importlib.util.find_spec("networkx") 53 | if networkx_spec is None: 54 | raise ImportError( 55 | "Networkx library not found. Please install networkx if you want to use the --networkx option." 56 | ) 57 | 58 | 59 | if __name__ == "__main__": 60 | parser = argparse.ArgumentParser(description="OSMtoRoadGraph") 61 | parser.add_argument("-f", "--file", action="store", type=str, dest="filename") 62 | parser.add_argument( 63 | "-n", 64 | "--networkType", 65 | dest="network_type", 66 | action="store", 67 | default="p", 68 | choices=["p", "b", "c"], 69 | help="(p)edestrian, (b)icycle, (c)ar, [default: p]", 70 | ) 71 | parser.add_argument("-l", "--nolcc", dest="lcc", action="store_true", default=False) 72 | parser.add_argument("-c", "--contract", dest="contract", action="store_true") 73 | parser.add_argument( 74 | "--networkx", 75 | dest="networkx_output", 76 | action="store_true", 77 | help="enable additional output of JSON format of networkx [note networkx needs to be installed for this to work].", 78 | default=False, 79 | ) 80 | 81 | options = parser.parse_args() 82 | 83 | filename = options.filename 84 | 85 | if filename is None: 86 | parser.print_help() 87 | sys.exit() 88 | 89 | try: 90 | if not os.path.isfile(filename): 91 | raise FileNotFoundError(f"Provided filename {filename} does not point to a file!") 92 | 93 | long_network_type = {"p": "pedestrian", "c": "car", "b": "bicycle"} 94 | if options.network_type in long_network_type: 95 | network_type = long_network_type[options.network_type] 96 | elif options.network_type == long_network_type.values(): 97 | network_type = options.network_type 98 | else: 99 | print("ERROR: network type improperly set") 100 | sys.exit(1) 101 | except FileNotFoundError as e: 102 | print(f"ERROR: {e}") 103 | sys.exit(1) 104 | except ValueError as e: 105 | print(f"ERROR: {e}") 106 | sys.exit(1) 107 | 108 | try: 109 | convert_osm_to_roadgraph(filename, network_type, options) 110 | except Exception as e: 111 | print(f"ERROR: {e}") 112 | sys.exit(1) 113 | -------------------------------------------------------------------------------- /osm/xml_handler.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from typing import Dict, List, Optional, Set 4 | from xml.sax.handler import ContentHandler 5 | from xml.sax.xmlreader import AttributesImpl 6 | 7 | from osm.osm_types import OSMNode, OSMWay 8 | from osm.way_parser_helper import WayParserHelper 9 | 10 | intern = sys.intern 11 | 12 | 13 | class PercentageFile: 14 | def __init__(self, filename: str) -> None: 15 | self.size = os.stat(filename)[6] 16 | self.delivered = 0 17 | self.f = open(filename, encoding="utf-8") 18 | self.percentages = [1000] + [100 - 10 * x for x in range(0, 11)] 19 | 20 | def read(self, size: Optional[int] = None) -> str: 21 | if size is None: 22 | self.delivered = self.size 23 | return self.f.read() 24 | data = self.f.read(size) 25 | self.delivered += len(data) 26 | 27 | if self.percentage >= self.percentages[-1]: 28 | if self.percentages[-1] < 100: 29 | print(f"{self.percentages[-1]}%..", end="") 30 | sys.stdout.flush() 31 | else: 32 | print("100%") 33 | self.percentages = self.percentages[:-1] 34 | return data 35 | 36 | def close(self) -> None: 37 | self.f.close() 38 | 39 | @property 40 | def percentage(self) -> float: 41 | return float(self.delivered) / self.size * 100.0 42 | 43 | 44 | class NodeHandler(ContentHandler): 45 | def __init__(self, found_nodes: Set[int]) -> None: 46 | self.found_nodes: Set[int] = found_nodes 47 | self.nodes: Dict[int, OSMNode] = {} 48 | 49 | def startElement(self, name: str, attrs: AttributesImpl) -> None: 50 | if name == "node": 51 | osm_id = int(attrs["id"]) 52 | if osm_id not in self.found_nodes: 53 | return 54 | 55 | self.nodes[osm_id] = OSMNode(osm_id, float(attrs["lat"]), float(attrs["lon"])) 56 | 57 | 58 | class WayHandler(ContentHandler): 59 | def __init__(self, parser_helper: WayParserHelper) -> None: 60 | self.found_ways: List[OSMWay] = [] 61 | self.found_nodes: Set[int] = set() 62 | 63 | self.current_way: Optional[OSMWay] = None 64 | 65 | self.parser_helper = parser_helper 66 | 67 | def startElement(self, name: str, attrs: AttributesImpl) -> None: 68 | if name == "way": 69 | self.current_way = OSMWay(osm_id=int(attrs["id"])) 70 | return 71 | 72 | if self.current_way is not None: 73 | try: 74 | if name == "nd": 75 | node_id = int(attrs["ref"]) 76 | self.current_way.add_node(node_id) 77 | 78 | elif name == "tag": 79 | if attrs["k"] == "highway": 80 | self.current_way.highway = attrs["v"] 81 | elif attrs["k"] == "area": 82 | self.current_way.area = attrs["v"] 83 | elif attrs["k"] == "maxspeed": 84 | self.current_way.max_speed_str = str(attrs["v"]) 85 | elif attrs["k"] == "oneway": 86 | if attrs["v"] == "yes": 87 | self.current_way.direction = "oneway" 88 | elif attrs["k"] == "name": 89 | try: 90 | self.current_way.name = intern(attrs["v"]) 91 | except TypeError: 92 | self.current_way.name = attrs["v"] 93 | elif attrs["k"] == "junction": 94 | if attrs["v"] == "roundabout": 95 | self.current_way.direction = "oneway" 96 | elif attrs["k"] == "indoor": 97 | # this is not an ideal solution since it sets the pedestrian flag irrespective of the real value in osm data 98 | # but aims to cover the simple indoor tagging approach: https://wiki.openstreetmap.org/wiki/Simple_Indoor_Tagging 99 | # more info: https://help.openstreetmap.org/questions/61025/pragmatic-single-level-indoor-paths 100 | if attrs["v"] == "corridor": 101 | self.current_way.highway = "pedestrian_indoor" 102 | except: 103 | e = sys.exc_info()[0] 104 | print(f"Error while parsing: {e}") 105 | 106 | def endElement(self, name: str) -> None: 107 | if name == "way": 108 | assert self.current_way is not None 109 | 110 | if not self.parser_helper.is_way_acceptable(self.current_way): 111 | self.current_way = None 112 | return 113 | 114 | self.found_nodes.update(self.current_way.nodes) 115 | 116 | self.current_way.max_speed_int = self.parser_helper.parse_max_speed(self.current_way) 117 | ( 118 | self.current_way.forward, 119 | self.current_way.backward, 120 | ) = self.parser_helper.parse_direction(self.current_way) 121 | 122 | self.found_ways.append(self.current_way) 123 | 124 | self.current_way = None 125 | -------------------------------------------------------------------------------- /examples/pycgr-to-png/pycgr-to-png.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import sys 3 | from dataclasses import dataclass 4 | from enum import Enum 5 | 6 | import utm 7 | from PIL import Image, ImageColor, ImageDraw 8 | 9 | 10 | @dataclass 11 | class Node: 12 | lat: float 13 | lon: float 14 | 15 | 16 | @dataclass 17 | class Edge: 18 | source_id: int 19 | target_id: int 20 | max_speed: int 21 | 22 | 23 | class InputState(Enum): 24 | NMB_NODES = 2 25 | NMB_EDGES = 3 26 | NODES = 4 27 | EDGES = 5 28 | 29 | 30 | def draw_graph(nodes, edges, width, height, text, out_filename): 31 | print("creating edges...") 32 | # convert to simple lines 33 | lines = [] 34 | max_x, max_y = float("-inf"), float("-inf") 35 | min_x, min_y = float("inf"), float("inf") 36 | 37 | for e in edges: 38 | if (e.source_id not in nodes) or (e.target_id not in nodes): 39 | print(f"didn't find {e.source_id} or {e.target_id}") 40 | continue 41 | 42 | s, t = nodes[e.source_id], nodes[e.target_id] 43 | sx, sy, _, _ = utm.from_latlon(s.lat, s.lon) 44 | tx, ty, _, _ = utm.from_latlon(t.lat, t.lon) 45 | sy, ty = -sy, -ty # need to invert y coordinates 46 | lines.append(((sx, sy), (tx, ty), e.max_speed)) 47 | max_x, max_y = max(max_x, sx, tx), max(max_y, sy, ty) 48 | min_x, min_y = min(min_x, sx, tx), min(min_y, sy, ty) 49 | 50 | print("drawing...") 51 | picture_width = width 52 | picture_height = height 53 | denominator = max((max_x - min_x) / picture_width, (max_y - min_y) / picture_height) 54 | im = Image.new("RGB", (picture_width, picture_height), "#FFF") 55 | draw = ImageDraw.Draw(im) 56 | for line_data in lines: 57 | # prepare coordinates to draw 58 | sx = (line_data[0][0] - min_x) / denominator 59 | tx = (line_data[1][0] - min_x) / denominator 60 | 61 | sy = (line_data[0][1] - min_y) / denominator 62 | ty = (line_data[1][1] - min_y) / denominator 63 | 64 | line = ((sx, sy), (tx, ty)) 65 | 66 | # determine color 67 | max_speed = line_data[2] 68 | luminosity = max(-8.0 / 5.0 * max_speed + 70.0, 0) 69 | color = ImageColor.getrgb(f"hsl(233, 74%, {luminosity}%)") 70 | 71 | # draw line 72 | draw.line(line, fill=color) 73 | 74 | draw.text((10, picture_height - 50), text, fill="#FF0000") 75 | draw.text( 76 | (10, picture_height - 30), 77 | "Map data © OpenStreetMap contributors", 78 | fill="#FF0000", 79 | ) 80 | del draw 81 | 82 | im.save(out_filename, "PNG") 83 | 84 | 85 | def read_file(filename): 86 | print(f"reading file {filename}...") 87 | state = InputState.NMB_NODES 88 | 89 | nodes = {} 90 | edges = [] 91 | nmb_nodes = None 92 | nmb_edges = None 93 | with open(filename, "r", encoding="utf-8") as input_file: 94 | for line in input_file: 95 | if line.startswith("#"): 96 | continue 97 | 98 | if state == InputState.NMB_NODES: 99 | nmb_nodes = int(line) 100 | state = InputState.NMB_EDGES 101 | elif state == InputState.NMB_EDGES: 102 | nmb_edges = int(line) 103 | state = InputState.NODES 104 | elif state == InputState.NODES: 105 | node_id, lat, lon = line.split(" ") 106 | nodes[int(node_id)] = Node(float(lat), float(lon)) 107 | if len(nodes) == nmb_nodes: 108 | state = InputState.EDGES 109 | elif state == InputState.EDGES: 110 | ( 111 | source_id, 112 | target_id, 113 | _, 114 | _, 115 | max_speed, 116 | _, 117 | ) = line.split(" ") 118 | edges.append(Edge(int(source_id), int(target_id), int(max_speed))) 119 | 120 | print(f"#nodes:{nmb_nodes}, #edges:{nmb_edges}") 121 | return nodes, edges 122 | 123 | 124 | if __name__ == "__main__": 125 | parser = argparse.ArgumentParser() 126 | parser.add_argument("-f", "--file", dest="in_filename", type=str) 127 | parser.add_argument("-o", "--out", dest="out_filename", type=str) 128 | parser.add_argument( 129 | "-t", 130 | "--text", 131 | dest="text", 132 | type=str, 133 | default="", 134 | help="text drawn in lower left corner", 135 | ) 136 | parser.add_argument( 137 | "--width", 138 | dest="width", 139 | type=int, 140 | default=1600, 141 | help="image width in px [default=1600]", 142 | ) 143 | parser.add_argument( 144 | "--height", 145 | dest="height", 146 | type=int, 147 | default=1200, 148 | help="image height in px [default=1200]", 149 | ) 150 | args = parser.parse_args() 151 | 152 | if (args.in_filename is None) or (args.out_filename is None): 153 | parser.print_help() 154 | sys.exit() 155 | 156 | nodes, edges = read_file(args.in_filename) 157 | draw_graph(nodes, edges, args.width, args.height, args.text, args.out_filename) 158 | -------------------------------------------------------------------------------- /examples/shortest-distances-drawing/shortest-distances-drawing.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import json 3 | import random 4 | import sys 5 | from collections import Counter 6 | from functools import lru_cache 7 | 8 | import networkx as nx 9 | import utm 10 | from PIL import Image, ImageColor, ImageDraw 11 | from tqdm import tqdm 12 | 13 | 14 | def load_graph(filename): 15 | with open(filename, "r", encoding="utf-8") as f: 16 | json_data = json.load(f) 17 | return nx.adjacency_graph(json_data) 18 | 19 | 20 | def find_approximate_central_node(G): 21 | print("finding approximate central node..") 22 | 23 | node_distances = Counter() 24 | start_nodes = random.sample(list(G.nodes), 20) 25 | 26 | for start_node_id in tqdm(start_nodes): 27 | lengths = Counter(nx.single_source_dijkstra_path_length(G, start_node_id)) 28 | node_distances += lengths 29 | 30 | central_node = min(node_distances, key=node_distances.get) 31 | print(f"taking node with id {central_node} as central node, with summed distance: {node_distances[central_node]}") 32 | return central_node 33 | 34 | 35 | @lru_cache(maxsize=None) 36 | def get_lat_lon(lat, lon): 37 | x, y, _, _ = utm.from_latlon(lat, lon) 38 | return x, y 39 | 40 | 41 | @lru_cache(maxsize=None) 42 | def get_color_str(luminosity): 43 | return f"hsl(233, 74%, {luminosity}%)" 44 | 45 | 46 | @lru_cache(maxsize=None) 47 | def get_color(luminosity): 48 | return ImageColor.getrgb(get_color_str(luminosity)) 49 | 50 | 51 | def draw_graph_on_map(G, lengths, output_filename, width=1600, height=1200): 52 | print("preparing to draw graph...") 53 | # convert to simple lines 54 | lines = [] 55 | max_x, max_y = float("-inf"), float("-inf") 56 | min_x, min_y = float("inf"), float("inf") 57 | max_distance = float("-inf") 58 | 59 | for e in tqdm(G.edges): 60 | s, t = e 61 | 62 | sx, sy = get_lat_lon(G.nodes[s]["lat"], G.nodes[s]["lon"]) 63 | tx, ty = get_lat_lon(G.nodes[t]["lat"], G.nodes[t]["lon"]) 64 | sy, ty = -sy, -ty # need to invert y coordinates 65 | 66 | if (s in lengths) and (t in lengths): 67 | distance = max(lengths[s], lengths[t]) 68 | elif s in lengths: 69 | distance = lengths[s] 70 | elif t in lengths: 71 | distance = lengths[t] 72 | else: 73 | distance = 0 74 | 75 | if distance < float("inf"): 76 | max_distance = max(max_distance, distance) 77 | 78 | lines.append(((sx, sy), (tx, ty), distance)) 79 | 80 | max_x, max_y = max(max_x, sx, tx), max(max_y, sy, ty) 81 | min_x, min_y = min(min_x, sx, tx), min(min_y, sy, ty) 82 | 83 | print("drawing...") 84 | picture_width = width 85 | picture_height = height 86 | denominator = max((max_x - min_x) / picture_width, (max_y - min_y) / picture_height) 87 | im = Image.new("RGB", (picture_width, picture_height), "#FFF") 88 | draw = ImageDraw.Draw(im) 89 | for line_data in tqdm(lines): 90 | # prepare coordinates to draw 91 | sx = (line_data[0][0] - min_x) / denominator 92 | tx = (line_data[1][0] - min_x) / denominator 93 | 94 | sy = (line_data[0][1] - min_y) / denominator 95 | ty = (line_data[1][1] - min_y) / denominator 96 | 97 | line = ((sx, sy), (tx, ty)) 98 | 99 | # determine color 100 | distance = line_data[2] 101 | if distance < float("inf"): 102 | luminosity = round(float(distance) / float(max_distance) * 100.0, 0) 103 | else: 104 | luminosity = 100.0 105 | 106 | color = get_color(luminosity) 107 | 108 | # draw line 109 | draw.line(line, fill=color) 110 | 111 | print(f"saving output file: {output_filename}") 112 | im.save(output_filename, "PNG") 113 | 114 | 115 | def travel_time(_u, _v, data): 116 | if not data["length"] or data["length"] == 0: 117 | return float("inf") 118 | return data["max_v"] / data["length"] 119 | 120 | 121 | def edge_length(_u, _v, data): 122 | return data["length"] or float("inf") 123 | 124 | 125 | if __name__ == "__main__": 126 | parser = argparse.ArgumentParser() 127 | parser.add_argument("-f", "--file", dest="in_filename", type=str, help="input networkx JSON file") 128 | parser.add_argument("-o", "--out", dest="out_filename", type=str, help="output png file") 129 | parser.add_argument( 130 | "-c", 131 | "--center", 132 | dest="center", 133 | action="store_true", 134 | help="set this option to compute the shortest distances from an approximate center node [default: random node]", 135 | ) 136 | parser.add_argument( 137 | "-m", 138 | "--metric", 139 | dest="metric", 140 | type=str, 141 | default="travel-time", 142 | choices=["length", "travel-time"], 143 | help="metric for the shortest path algorithm. Either 'length' or 'travel-time' [default: travel-time]", 144 | ) 145 | parser.add_argument( 146 | "--width", 147 | dest="width", 148 | type=int, 149 | default=1600, 150 | help="image width in px [default=1600]", 151 | ) 152 | parser.add_argument( 153 | "--height", 154 | dest="height", 155 | type=int, 156 | default=1200, 157 | help="image height in px [default=1200]", 158 | ) 159 | args = parser.parse_args() 160 | 161 | if (args.in_filename is None) or (args.out_filename is None): 162 | parser.print_help() 163 | sys.exit(-1) 164 | 165 | G = load_graph(args.in_filename) 166 | if args.center: 167 | start_node = find_approximate_central_node(G) 168 | else: 169 | start_node = random.choice(list(G)) 170 | 171 | metric = None 172 | if args.metric == "travel-time": 173 | metric = travel_time 174 | elif args.metric == "length": 175 | metric = edge_length 176 | else: 177 | print(f"Did not recognize --metric/-m option. Provided {args.metric}. Must either be 'travel-time' or 'length'") 178 | 179 | lengths = nx.single_source_dijkstra_path_length(G, start_node, cutoff=None, weight=metric) 180 | draw_graph_on_map(G, lengths, output_filename=args.out_filename) 181 | -------------------------------------------------------------------------------- /graph/contract_graph.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict, deque 2 | from dataclasses import replace 3 | from typing import DefaultDict, List, Set, Tuple 4 | 5 | from graph import graphfactory 6 | from graph.graph import Graph 7 | from graph.graph_types import Edge, Vertex 8 | from utils import timer 9 | 10 | 11 | class ContractGraph: 12 | def __init__(self, graph: Graph) -> None: 13 | self.graph = graph 14 | 15 | @timer.timer 16 | def contract(self): 17 | self.out_edges_per_node = self._get_out_edges() 18 | all_new_edges = self._find_new_edges() 19 | node_ids = self._gather_node_ids(all_new_edges) 20 | nodes = self._get_nodes(node_ids) 21 | 22 | print( 23 | f"finished contracting: {len(all_new_edges)}/{len(self.graph.edges)} edges and {len(nodes)}/{len(self.graph.vertices)} vertices." 24 | ) 25 | 26 | return graphfactory.build_graph_from_vertices_edges(nodes, all_new_edges) 27 | 28 | def _find_new_edges(self) -> Set[Edge]: 29 | # maintain a list L of nodes from which we want to start searches to find new contracted edges 30 | # initialize L with all intersection nodes (i.e., all nodes with degree != 2) 31 | # for each node n in L 32 | # for each of n's neighbor 33 | # search until: 34 | # - an intersection node is found or edge is found 35 | # - the next edge is different in it's structure (e.g., different highway type, different max speed, ...) 36 | self.start_nodes = self._find_all_intersections() 37 | self.seen_start_nodes = set(self.start_nodes) 38 | new_edges = set() 39 | bidirectional_edges: Set[Tuple[int, int]] = set() 40 | 41 | out_edges_per_node = self._get_out_edges() 42 | while len(self.start_nodes) > 0: 43 | node_id = self.start_nodes.popleft() 44 | 45 | out_edges = out_edges_per_node[node_id] 46 | for first_out_edge in out_edges: 47 | start_node_id = node_id 48 | 49 | edges_to_merge, final_node_id = self._find_edges_to_merge(start_node_id, first_out_edge) 50 | 51 | if len(edges_to_merge) == 0: 52 | continue 53 | 54 | sum_edge_lengths = sum(e.data.length for e in edges_to_merge) 55 | data = replace(edges_to_merge[0].data, length=sum_edge_lengths) 56 | 57 | if edges_to_merge[0].backward: 58 | # deduplication measure; if not for this for bidirectional edges, that are 59 | # removed between intersections, 2 new edges would be created 60 | smaller_node_id = start_node_id if start_node_id < final_node_id else final_node_id 61 | bigger_node_id = start_node_id if start_node_id > final_node_id else final_node_id 62 | if (smaller_node_id, bigger_node_id) in bidirectional_edges: 63 | # already added this edge skip it 64 | continue 65 | bidirectional_edges.add((smaller_node_id, bigger_node_id)) 66 | merged_edge = Edge( 67 | smaller_node_id, 68 | bigger_node_id, 69 | True, 70 | edges_to_merge[0].backward, 71 | data, 72 | ) 73 | else: 74 | merged_edge = Edge( 75 | start_node_id, 76 | final_node_id, 77 | True, 78 | edges_to_merge[0].backward, 79 | data, 80 | ) 81 | new_edges.add(merged_edge) 82 | return new_edges 83 | 84 | def _find_edges_to_merge(self, start_node_id: int, first_out_edge: Edge) -> Tuple[List[Edge], int]: 85 | # walk from start_node along first_out_edge until: 86 | # i) another intersection node is found 87 | # ii) an edge is encountered on the way that is different (different name, max_speed, ...) 88 | # iii) the start_node is found => loop [remove it completely] 89 | used_edges = [] 90 | out_edge = first_out_edge 91 | current_node_id = start_node_id 92 | while True: 93 | used_edges.append(out_edge) 94 | next_node_id = out_edge.t if out_edge.s == current_node_id else out_edge.s 95 | 96 | if next_node_id == start_node_id: 97 | # detected a loop => remove it 98 | used_edges = [] 99 | break 100 | 101 | if self._is_intersection(next_node_id): 102 | break 103 | 104 | next_out_edges = list( 105 | filter( 106 | lambda e: current_node_id not in (e.s, e.t), 107 | self.out_edges_per_node[next_node_id], 108 | ) 109 | ) 110 | 111 | if len(next_out_edges) == 0: 112 | # detected a dead end => stop 113 | break 114 | 115 | if len(next_out_edges) > 1: 116 | # something is wrong.. this should have been filtered out by the intersection check 117 | raise AssertionError() 118 | 119 | next_out_edge = next_out_edges[0] 120 | if self._is_not_same_edge(out_edge, next_out_edge): 121 | if next_node_id not in self.seen_start_nodes: 122 | # found a new possible start node 123 | self.seen_start_nodes.add(next_node_id) 124 | self.start_nodes.append(next_node_id) 125 | # break since we need to stop here 126 | break 127 | 128 | out_edge = next_out_edge 129 | current_node_id = next_node_id 130 | 131 | final_node_id = next_node_id 132 | return used_edges, final_node_id 133 | 134 | def _is_not_same_edge(self, e1: Edge, e2: Edge) -> bool: 135 | return ( 136 | e1.data.highway != e2.data.highway 137 | or e1.data.max_v != e2.data.max_v 138 | or e1.data.name != e2.data.name 139 | or e1.backward != e2.backward 140 | ) 141 | 142 | def _get_out_edges(self) -> DefaultDict[int, List[Edge]]: 143 | result: DefaultDict[int, List[Edge]] = defaultdict(list) 144 | for e in self.graph.edges: 145 | if e.forward: 146 | result[e.s].append(e) 147 | if e.backward: 148 | result[e.t].append(e) 149 | return result 150 | 151 | def _find_all_intersections(self) -> deque: 152 | node_ids = range(0, len(self.graph.vertices)) 153 | return deque(filter(self._is_intersection, node_ids)) 154 | 155 | def _get_nodes(self, node_ids: Set[int]) -> List[Vertex]: 156 | return list(map(self.graph.get_node, node_ids)) 157 | 158 | def _gather_node_ids(self, edges: List[Edge]) -> Set[int]: 159 | print("\t gathering nodes...") 160 | node_ids = set() 161 | for e in edges: 162 | node_ids.add(e.s) 163 | node_ids.add(e.t) 164 | return node_ids 165 | 166 | def _is_intersection(self, node_id: int) -> bool: 167 | return len(self.graph.all_neighbors(node_id)) != 2 168 | -------------------------------------------------------------------------------- /tests/contract_graph_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from graph.contract_graph import ContractGraph 4 | from graph.graph import Graph 5 | from graph.graph_types import Edge, EdgeData, Vertex, VertexData 6 | 7 | 8 | class ContractGraphTest(unittest.TestCase): 9 | def test_contract_a_path_to_two_nodes_and_one_edge(self): 10 | g = self.create_graph(number_nodes=3) 11 | 12 | g.add_edge(Edge(s=0, t=1, forward=True, backward=True, data=self.edge_data())) 13 | g.add_edge(Edge(s=1, t=2, forward=True, backward=True, data=self.edge_data())) 14 | 15 | contracted_graph = ContractGraph(g).contract() 16 | 17 | self.assertEqual(len(contracted_graph.vertices), 2) 18 | self.assertEqual(len(contracted_graph.edges), 1) 19 | 20 | def test_contract_loop_to_nothing(self): 21 | g = self.create_graph(number_nodes=4) 22 | 23 | # loop 24 | g.add_edge(Edge(s=0, t=1, forward=True, backward=True, data=self.edge_data())) 25 | g.add_edge(Edge(s=1, t=2, forward=True, backward=True, data=self.edge_data())) 26 | g.add_edge(Edge(s=2, t=0, forward=True, backward=True, data=self.edge_data())) 27 | 28 | # connection (otherwise no intersections will be found) 29 | g.add_edge(Edge(s=0, t=3, forward=True, backward=True, data=self.edge_data())) 30 | 31 | contracted_graph = ContractGraph(g).contract() 32 | 33 | self.assertEqual(len(contracted_graph.vertices), 2) 34 | self.assertEqual(len(contracted_graph.edges), 1) 35 | 36 | def test_contracting_stops_if_edge_is_different(self): 37 | g = self.create_graph(number_nodes=10) 38 | 39 | g.add_edge( 40 | Edge( 41 | s=0, 42 | t=1, 43 | forward=True, 44 | backward=True, 45 | data=self.edge_data(length=2, name="abc"), 46 | ) 47 | ) 48 | g.add_edge( 49 | Edge( 50 | s=1, 51 | t=2, 52 | forward=True, 53 | backward=True, 54 | data=self.edge_data(length=3, name="abc"), 55 | ) 56 | ) 57 | g.add_edge( 58 | Edge( 59 | s=2, 60 | t=3, 61 | forward=True, 62 | backward=True, 63 | data=self.edge_data(length=5, name="def"), 64 | ) 65 | ) 66 | g.add_edge( 67 | Edge( 68 | s=3, 69 | t=4, 70 | forward=True, 71 | backward=True, 72 | data=self.edge_data(length=7, name="def"), 73 | ) 74 | ) 75 | g.add_edge( 76 | Edge( 77 | s=4, 78 | t=5, 79 | forward=True, 80 | backward=True, 81 | data=self.edge_data(length=11, name="ghi"), 82 | ) 83 | ) 84 | g.add_edge( 85 | Edge( 86 | s=5, 87 | t=6, 88 | forward=True, 89 | backward=True, 90 | data=self.edge_data(length=13, name="ghi"), 91 | ) 92 | ) 93 | g.add_edge( 94 | Edge( 95 | s=6, 96 | t=7, 97 | forward=True, 98 | backward=True, 99 | data=self.edge_data(length=17, name="jkl"), 100 | ) 101 | ) 102 | g.add_edge( 103 | Edge( 104 | s=7, 105 | t=8, 106 | forward=True, 107 | backward=True, 108 | data=self.edge_data(length=23, name="mno"), 109 | ) 110 | ) 111 | g.add_edge( 112 | Edge( 113 | s=8, 114 | t=9, 115 | forward=True, 116 | backward=True, 117 | data=self.edge_data(length=29, name="mno"), 118 | ) 119 | ) 120 | # input: 0-1-2-3-4-5-6-7-8-9 121 | # expected outcome: 0-2-4-6-7-9 122 | 123 | contracted_graph = ContractGraph(g).contract() 124 | 125 | self.assertEqual(len(contracted_graph.vertices), 6) 126 | self.assertEqual(len(contracted_graph.edges), 5) 127 | 128 | result_edge_data = [e.data for e in contracted_graph.edges] 129 | expected_edge_data = [ 130 | self.edge_data(length=5, name="abc"), 131 | self.edge_data(length=12, name="def"), 132 | self.edge_data(length=24, name="ghi"), 133 | self.edge_data(length=17, name="jkl"), 134 | self.edge_data(length=52, name="mno"), 135 | ] 136 | self.assertCountEqual(result_edge_data, expected_edge_data) 137 | 138 | def test_contracting_stops_at_intersections(self): 139 | g = self.create_graph(number_nodes=7) 140 | 141 | # path of 5 nodes 142 | g.add_edge(Edge(s=0, t=1, forward=True, backward=True, data=self.edge_data())) 143 | g.add_edge(Edge(s=1, t=2, forward=True, backward=True, data=self.edge_data())) 144 | g.add_edge(Edge(s=2, t=3, forward=True, backward=True, data=self.edge_data())) 145 | g.add_edge(Edge(s=3, t=4, forward=True, backward=True, data=self.edge_data())) 146 | 147 | # path of two nodes attached in the middle of the path above 148 | g.add_edge(Edge(s=2, t=5, forward=True, backward=True, data=self.edge_data())) 149 | g.add_edge(Edge(s=5, t=6, forward=True, backward=True, data=self.edge_data())) 150 | 151 | # expected outcome: 4 nodes remain, and 3 edges, one deg 3 node, all others are deg 1 nodes 152 | 153 | contracted_graph = ContractGraph(g).contract() 154 | 155 | self.assertEqual(len(contracted_graph.vertices), 4) 156 | self.assertEqual(len(contracted_graph.edges), 3) 157 | 158 | nmb_neigbors = [len(contracted_graph.all_neighbors(n_id)) for n_id in range(4)] 159 | self.assertCountEqual(nmb_neigbors, [3, 1, 1, 1]) 160 | 161 | def test_no_contraction_if_each_edge_is_different(self): 162 | g = self.create_graph(number_nodes=6) 163 | 164 | g.add_edge(Edge(s=0, t=1, forward=True, backward=True, data=self.edge_data(name="a"))) 165 | g.add_edge( 166 | Edge( 167 | s=1, 168 | t=2, 169 | forward=True, 170 | backward=True, 171 | data=self.edge_data(name="a", highway="b"), 172 | ) 173 | ) 174 | g.add_edge( 175 | Edge( 176 | s=2, 177 | t=3, 178 | forward=True, 179 | backward=False, 180 | data=self.edge_data(name="a", highway="b"), 181 | ) 182 | ) 183 | g.add_edge( 184 | Edge( 185 | s=3, 186 | t=4, 187 | forward=True, 188 | backward=True, 189 | data=self.edge_data(name="a", highway="b"), 190 | ) 191 | ) 192 | g.add_edge( 193 | Edge( 194 | s=4, 195 | t=5, 196 | forward=True, 197 | backward=True, 198 | data=self.edge_data(name="a", highway="b", max_v=123), 199 | ) 200 | ) 201 | 202 | contracted_graph = ContractGraph(g).contract() 203 | 204 | self.assertEqual(len(contracted_graph.vertices), 6) 205 | self.assertEqual(len(contracted_graph.edges), 5) 206 | 207 | result_edge_data = [e.data for e in contracted_graph.edges] 208 | expected_edge_data = [ 209 | self.edge_data(name="a"), 210 | self.edge_data(name="a", highway="b"), 211 | self.edge_data(name="a", highway="b"), 212 | self.edge_data(name="a", highway="b"), 213 | self.edge_data(name="a", highway="b", max_v=123), 214 | ] 215 | self.assertCountEqual(result_edge_data, expected_edge_data) 216 | 217 | def create_graph(self, number_nodes): 218 | g = Graph() 219 | for i in range(number_nodes): 220 | g.add_node(self.vertex(i)) 221 | return g 222 | 223 | def edge_data(self, length=1, highway="", max_v=50, name=""): 224 | return EdgeData(length=length, highway=highway, max_v=max_v, name=name) 225 | 226 | def vertex(self, index): 227 | return Vertex(index, VertexData(0, 0)) 228 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OsmToRoadGraph v.0.7.0 2 | 3 | ![Build Status](https://github.com/AndGem/OsmToRoadGraph/workflows/Build%20Status/badge.svg?branch=master) 4 | [![codecov](https://codecov.io/gh/AndGem/OsmToRoadGraph/branch/master/graph/badge.svg)](https://codecov.io/gh/AndGem/OsmToRoadGraph) 5 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 6 | 7 | - [OsmToRoadGraph v.0.7.0](#osmtoroadgraph-v060) 8 | - [Updates](#updates) 9 | - [Introduction](#introduction) 10 | - [Motivation](#motivation) 11 | - [Description](#description) 12 | - [Requirements](#requirements) 13 | - [Older Versions](#older-versions) 14 | - [Usage](#usage) 15 | - [Usage - Explanation](#usage---explanation) 16 | - [Examples](#examples) 17 | - [Output](#output) 18 | - [Output Format](#output-format) 19 | - [Example Road Network (*.pycgr)](#example-road-network-pycgr) 20 | - [Example Street Names (*.pycgr_names)](#example-street-names-pycgr_names) 21 | - [Configuring the Accepted OSM Highway Types](#configuring-the-accepted-osm-highway-types) 22 | - [Indoor Paths](#indoor-paths) 23 | - [Research](#research) 24 | 25 | ## Updates 26 | 27 | 28 | **Changelog v.0.6.0c -> v.0.7.0:** 29 | 30 | - [x] Upgraded to Python 3.13 requirement 31 | - [x] pyproject.toml added 32 | - [x] minor code improvements 33 | 34 | ## Introduction 35 | 36 | OSMtoRoadGraph aims to provide a simple tool to allow extraction of the road network of [OpenStreetMap](http://www.openstreetmap.org) files. It differentiates between three transportation networks: car, bicycle, and walking. The output data depends on the chosen parameters (which street highway types to consider, speed, ...). 37 | 38 | ### Motivation 39 | 40 | OpenStreetMap provides free cartographic data to anyone. Data can be added and edited by anyone. However, using the road network contained in the OSM files is not straightforward. This tool aims to reduce the overhead of writing a parser for OSM files. 41 | 42 | Below is an example of a visualization of the road network of the city of Bremen, Germany. The darker the shade of the street, the higher the maximum allowed speed. 43 | 44 | 45 | 46 | For details on how the image was generated take a look into the [examples folder](https://github.com/AndGem/OsmToRoadGraph/tree/master/examples/pycgr-to-png). 47 | 48 | ### Description 49 | 50 | With this tool, osm data can be converted into easily parsable plaintext files that can be used by any application for further processing. The program generates with default input parameters two output files. One file contains the nodes (with coordinates), and the network edges with length, direction, and maximum speed (according to chosen network type). The second file contains street names for all edges for which the data is available. 51 | 52 | As an additional feature, and to make interaction easier, since version 0.5 OsmToRoadGraph supports to produce output in a [networkx json](https://networkx.github.io/documentation/stable/reference/readwrite/json_graph.html?highlight=json#module-networkx.readwrite.json_graph). 53 | 54 | ### Requirements 55 | 56 | - Python 3.13+/PyPy 57 | - An OSM XML file 58 | - [Optional: [networkx](https://networkx.github.io/) as dependency: `pip3 install networkx`] 59 | 60 | ### Older Versions 61 | 62 | Recently, breaking changes have been applied. If you require older versions please see the [releases](https://github.com/AndGem/OsmToRoadGraph/releases). 63 | 64 | ### Usage 65 | 66 | ```bash 67 | usage: run.py [-h] [-f FILENAME] [-n {p,b,c}] [-l] [-c] [--networkx] 68 | 69 | optional arguments: 70 | -h, --help show this help message and exit 71 | -f FILENAME, --file FILENAME 72 | -n {p,b,c}, --networkType {p,b,c} 73 | (p)edestrian, (b)icycle, (c)ar, [default: p] 74 | -l, --nolcc 75 | -c, --contract 76 | --networkx enable additional output of JSON format of networkx 77 | [note networkx needs to be installed for this to 78 | work]. 79 | ``` 80 | 81 | #### Usage - Explanation 82 | 83 | `-f` points to the input filename; the output files will be created in the same folder and using the name of the input file as prefix and suffixes depending on the network type. 84 | This filename must be either an OSM XML file (usually has the file extension `.osm`) or such a file compressed by bz2 (usually has the file extension `.bz2`). 85 | If it is a bz2 file, the content will be decompressed in memory. 86 | 87 | `-n` sets the network type. This influences which edges are selected, their maximum speed, and if direction is important (it is assumed pedestrians can always traverse every edge in both directions, and their maximum speed is 5kmh). If you want to fine-tune this for your needs, see [Configuring the Accepted OSM Highway Types](#configuring-the-accepted-osm-highway-types). 88 | 89 | `-l` if you set this option the graph will be output as a whole but _may_ contain unconnected components. By default, the largest connected component is determined and the rest is dropped. 90 | 91 | `-c` if you specify this flag additional to the original graph, a second pair of filenames will be created containing the result of contracting all degree 2 nodes. 92 | 93 | `--networkx` if you specify this flag, an additional output file will be generated using networkx's [networkx.readwrite.json_graph.adjacency_data](https://networkx.github.io/documentation/stable/reference/readwrite/generated/networkx.readwrite.json_graph.adjacency_data.html#networkx.readwrite.json_graph.adjacency_data). This also works with the flag `-c`. Then, a non-contracted and contracted output file compatible to networkx will be generated. 94 | 95 | Example execution: 96 | 97 | ```bash 98 | python run.py -f data/karlsruhe_small.osm -n p -v 99 | ``` 100 | 101 | #### Examples 102 | 103 | To see what you can do with the output please have a look here: 104 | 105 | - [pycgr to png](https://github.com/AndGem/OsmToRoadGraph/tree/master/examples/pycgr-to-png): small python script that loads the output of this program and generates a drawing of a road network 106 | - [shortest distances drawing](https://github.com/AndGem/OsmToRoadGraph/tree/master/examples/shortest-distances-drawing): another python script that loads the **networkx** output of this program and generates a drawing of the road network and uses colors to encode distances. 107 | 108 | #### Output 109 | 110 | The output will consist of two plaintext files. One file ending in `.pypgr`, `pybgr`, or `pycgr` depending on the network type selected; the other file will have the same ending with the additional suffix `_names`. The first file contains the graph structure as well as additional information about the edge (length, max speed according to highway type, if it is a one-way street or not). The file ending with `_names` includes the street names for the edges. 111 | 112 | If the option `--networkx` is specified, there will be an additional output file with the file extension `.json`. See [networkx.readwrite.json_graph.adjacency_data](https://networkx.github.io/documentation/stable/reference/readwrite/generated/networkx.readwrite.json_graph.adjacency_data.html#networkx.readwrite.json_graph.adjacency_data) for more details. 113 | 114 | ##### Output Format 115 | 116 | The structure of the road network output file is the following: 117 | 118 | ``` 119 |
120 | 121 | 122 | 123 | ... 124 | 125 | ... 126 | ``` 127 | 128 | The file begins with a header (some lines with a # character). 129 | 130 | Then, two lines follow that contain the `number of nodes` and the `number of edges`. 131 | After this, two larger blocks follow. In the first block, the nodes are being described, and in the latter the edges are described. 132 | The first block consists of `` many lines, and the second block consists of `` many lines. 133 | 134 | The nodes of the graph are described by the following three parameters. Each node's data is stored in one line, and the parameters are separated by a space: 135 | 136 | - ``: the node id (used later in the part where edges are to describe which nodes are connected by an edge) 137 | - ``: latitude of the node 138 | - ``: longitude of the node 139 | 140 | Edges of the graph are described by 6 parameters. Each edge is stored in one line, and the parameters are separated by a space: 141 | 142 | - ``: the node id (see above) from which the edge originates 143 | - ``: the node id (see above) to which the edge leads to 144 | - ``: the length of the edge in meters (approximated) 145 | - ``: one of the OSM highway types (see: https://wiki.openstreetmap.org/wiki/Key:highway) 146 | - ``: maximum allowed speed (if exists) in km/h [note: if no max speed is found a default value will be used] 147 | - `` indicates if an edge is bidirectional. The value is `0` if it is a unidirectional road (from `source_node_id` to `target_node_id`), and otherwise it is `1`. 148 | 149 | ###### Example Road Network (*.pycgr) 150 | 151 | ``` 152 | # Road Graph File v.0.4 153 | # number of nodes 154 | # number of edges 155 | # node_properties 156 | # ... 157 | # edge_properties 158 | # ... 159 | 4108 160 | 4688 161 | 0 49.0163448 8.4019855 162 | 1 49.0157261 8.405539 163 | 2 49.0160334 8.4050578 164 | ... 165 | 531 519 93.87198088764158 service 10 0 166 | 524 528 71.98129087573543 service 10 1 167 | 528 532 22.134814663337743 service 10 1 168 | 532 530 12.012991347084839 service 10 1 169 | 530 531 12.76035560927566 service 10 1 170 | 531 529 14.981628728184265 service 10 1 171 | 529 501 77.18577344768484 service 10 1 172 | 501 502 10.882105497189313 service 10 1 173 | 75 405 14.312976598760008 residential 30 1 174 | 405 206 44.642284586584886 residential 30 1 175 | ... 176 | ``` 177 | 178 | ###### Example Street Names (*.pycgr_names) 179 | 180 | ``` 181 | Hölderlinstraße 182 | Hölderlinstraße 183 | Hölderlinstraße 184 | 185 | Kronenstraße 186 | Kronenstraße 187 | Kronenstraße 188 | 189 | Zähringerstraße 190 | Zähringerstraße 191 | ``` 192 | 193 | Each line consists of a street name. The number in which a line is corresponds to the edge's index. In this example. this means, that Hölderlinstraße is the street name of edges 0, 1, 2. The absence of a name in line 4 indicates that edge 3 has no street name. Edges 4, 5, 6 have street name Kronenstraße, and so on... 194 | 195 | #### Configuring the Accepted OSM Highway Types 196 | 197 | The application comes with a set of standard configuration to parse `only` some OSM ways that have the tag `highway=x` where `x` is a [highway type](https://wiki.openstreetmap.org/wiki/Key:highway) [notable excepting is the `pedestrian_indoors`, see below for an explanation]. 198 | You can change the behavior of this program by changing the values (removing unwanted, and adding missing values) in the `configuration.py`. 199 | In this file, you can also modify the speed limit that will be written to the output file. 200 | 201 | #### Indoor Paths 202 | 203 | By default elements tagged by the [Simple Indoor Tagging](https://wiki.openstreetmap.org/wiki/Simple_Indoor_Tagging) approach are being ignored. 204 | To enable to also to extract these paths replace in `configuration.py` the line 205 | 206 | ```python 207 | accepted_highways['pedestrian'] = set(["primary", "secondary", "tertiary", "unclassified", "residential", "service", "primary_link", "secondary_link", "tertiary_link", "living_street", "pedestrian", "track", "road", "footway", "steps", "path"]) 208 | ``` 209 | 210 | with 211 | 212 | ```python 213 | accepted_highways['pedestrian'] = set(["primary", "secondary", "tertiary", "unclassified", "residential", "service", "primary_link", "secondary_link", "tertiary_link", "living_street", "pedestrian", "track", "road", "footway", "steps", "path", "pedestrian_indoor"]) 214 | ``` 215 | 216 | Note that the change is only the addition of `pedestrian_indoor` in this list. 217 | 218 | ### Research 219 | 220 | [Temporal Map Labeling: A New Unified Framework with Experiments](http://i11www.iti.uni-karlsruhe.de/temporallabeling/) 221 | --------------------------------------------------------------------------------