From 5bcb47b55bf02b40daae2643cffe19b1625b1fb1 Mon Sep 17 00:00:00 2001 From: Grufoony Date: Wed, 21 Jan 2026 15:02:58 +0100 Subject: [PATCH 1/5] Change return of get_cartography function --- src/dsf/python/cartography.py | 45 ++++++++++++++--------------------- test/Test_cartography.py | 5 +--- 2 files changed, 19 insertions(+), 31 deletions(-) diff --git a/src/dsf/python/cartography.py b/src/dsf/python/cartography.py index 0fb95662..eb248b0d 100644 --- a/src/dsf/python/cartography.py +++ b/src/dsf/python/cartography.py @@ -21,8 +21,7 @@ def get_cartography( consolidate_intersections: bool | float = 10, dead_ends: bool = False, infer_speeds: bool = False, - return_type: str = "gdfs", -) -> tuple | nx.DiGraph: +) -> tuple[nx.DiGraph, gpd.GeoDataFrame, gpd.GeoDataFrame]: """ Retrieves and processes cartography data for a specified place using OpenStreetMap data. @@ -44,16 +43,14 @@ def get_cartography( infer_speeds (bool, optional): Whether to infer edge speeds based on road types. Defaults to False. If True, calls ox.routing.add_edge_speeds using np.nanmedian as aggregation function. Finally, the "maxspeed" attribute is replaced with the inferred "speed_kph", and the "travel_time" attribute is computed. - return_type (str, optional): Type of return value. Options are "gdfs" (GeoDataFrames) or - "graph" (NetworkX DiGraph). Defaults to "gdfs". Returns: - tuple | nx.DiGraph: If return_type is "gdfs", returns a tuple containing two GeoDataFrames: + tuple[nx.DiGraph, gpd.GeoDataFrame, gpd.GeoDataFrame]: Returns a tuple containing: + - NetworkX DiGraph with standardized attributes. - gdf_edges: GeoDataFrame with processed edge data, including columns like 'source', 'target', 'nlanes', 'type', 'name', 'id', and 'geometry'. - gdf_nodes: GeoDataFrame with processed node data, including columns like 'id', 'type', and 'geometry'. - If return_type is "graph", returns the NetworkX DiGraph with standardized attributes. """ if bbox is None and place_name is None: raise ValueError("Either place_name or bbox must be provided.") @@ -223,32 +220,26 @@ def get_cartography( ): # Check for NaN G.nodes[node]["type"] = "N/A" - # Return graph or GeoDataFrames based on return_type - if return_type == "graph": - return G - elif return_type == "gdfs": - # Convert back to MultiDiGraph temporarily for ox.graph_to_gdfs compatibility - gdf_nodes, gdf_edges = ox.graph_to_gdfs(nx.MultiDiGraph(G)) + # Convert back to MultiDiGraph temporarily for ox.graph_to_gdfs compatibility + gdf_nodes, gdf_edges = ox.graph_to_gdfs(nx.MultiDiGraph(G)) - # Reset index and drop unnecessary columns (id, source, target already exist from graph) - gdf_edges.reset_index(inplace=True) - # Move the "id" column to the beginning - id_col = gdf_edges.pop("id") - gdf_edges.insert(0, "id", id_col) + # Reset index and drop unnecessary columns (id, source, target already exist from graph) + gdf_edges.reset_index(inplace=True) + # Move the "id" column to the beginning + id_col = gdf_edges.pop("id") + gdf_edges.insert(0, "id", id_col) - # Ensure length is float - gdf_edges["length"] = gdf_edges["length"].astype(float) + # Ensure length is float + gdf_edges["length"] = gdf_edges["length"].astype(float) - gdf_edges.drop(columns=["u", "v", "key"], inplace=True, errors="ignore") + gdf_edges.drop(columns=["u", "v", "key"], inplace=True, errors="ignore") - # Reset index for nodes - gdf_nodes.reset_index(inplace=True) - gdf_nodes.drop(columns=["y", "x"], inplace=True, errors="ignore") - gdf_nodes.rename(columns={"osmid": "id"}, inplace=True) + # Reset index for nodes + gdf_nodes.reset_index(inplace=True) + gdf_nodes.drop(columns=["y", "x"], inplace=True, errors="ignore") + gdf_nodes.rename(columns={"osmid": "id"}, inplace=True) - return gdf_edges, gdf_nodes - else: - raise ValueError("Invalid return_type. Choose 'gdfs' or 'graph'.") + return G, gdf_edges, gdf_nodes def graph_from_gdfs( diff --git a/test/Test_cartography.py b/test/Test_cartography.py index df08121e..161edee8 100644 --- a/test/Test_cartography.py +++ b/test/Test_cartography.py @@ -17,10 +17,7 @@ def test_consistency(): A simple consistency test to verify that converting from GeoDataFrames to graph and back yields the same GeoDataFrames. """ - G_CART = get_cartography("Postua, Piedmont, Italy", return_type="graph") - edges_cart, nodes_cart = get_cartography( - "Postua, Piedmont, Italy", return_type="gdfs" - ) + G_CART, edges_cart, nodes_cart = get_cartography("Postua, Piedmont, Italy") edges, nodes = graph_to_gdfs(G_CART) From 087d1f6ca05b352f172c845fa32f5bb21cd4ed5d Mon Sep 17 00:00:00 2001 From: Grufoony Date: Wed, 21 Jan 2026 15:19:33 +0100 Subject: [PATCH 2/5] Add to_folium_map function --- src/dsf/__init__.py | 1 + src/dsf/python/cartography.py | 47 +++++++++++++++++++++++++++ test/Test_cartography.py | 61 +++++++++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+) diff --git a/src/dsf/__init__.py b/src/dsf/__init__.py index e35d3d92..9a7d3c6f 100644 --- a/src/dsf/__init__.py +++ b/src/dsf/__init__.py @@ -21,4 +21,5 @@ graph_from_gdfs, graph_to_gdfs, create_manhattan_cartography, + to_folium_map, ) diff --git a/src/dsf/python/cartography.py b/src/dsf/python/cartography.py index eb248b0d..1083997b 100644 --- a/src/dsf/python/cartography.py +++ b/src/dsf/python/cartography.py @@ -7,6 +7,7 @@ standardization of attributes. """ +import folium import geopandas as gpd import networkx as nx import numpy as np @@ -451,6 +452,52 @@ def create_manhattan_cartography( return gdf_edges, gdf_nodes +def to_folium_map( + G: nx.DiGraph, + which: str = "edges", +) -> folium.Map: + """ + Converts a NetworkX DiGraph to a Folium map for visualization. + Args: + G (nx.DiGraph): The input DiGraph. + which (str): Specify whether to visualize 'edges', 'nodes', or 'both'. Defaults to 'edges'. + Returns: + folium.Map: The Folium map with the graph visualized. + """ + + # Compute mean latitude and longitude for centering the map + mean_lat = np.mean([data["y"] for _, data in G.nodes(data=True)]) + mean_lon = np.mean([data["x"] for _, data in G.nodes(data=True)]) + folium_map = folium.Map(location=[mean_lat, mean_lon], zoom_start=13) + + if which in ("edges", "both"): + # Add edges to the map + for _, _, data in G.edges(data=True): + line = data.get("geometry") + if line: + folium.PolyLine( + locations=[(point[1], point[0]) for point in line.coords], + color="blue", + weight=2, + opacity=0.7, + popup=f"Edge ID: {data.get('id')}", + ).add_to(folium_map) + if which in ("nodes", "both"): + # Add nodes to the map + for _, data in G.nodes(data=True): + folium.CircleMarker( + location=(data["y"], data["x"]), + radius=5, + color="red", + fill=True, + fill_color="red", + fill_opacity=0.7, + popup=f"Node ID: {data.get('id')}", + ).add_to(folium_map) + + return folium_map + + # if __name__ == "__main__": # # Produce data for tests # edges, nodes = get_cartography( diff --git a/test/Test_cartography.py b/test/Test_cartography.py index 161edee8..0a34be96 100644 --- a/test/Test_cartography.py +++ b/test/Test_cartography.py @@ -4,11 +4,13 @@ import pytest import networkx as nx +import folium from dsf.python.cartography import ( get_cartography, graph_to_gdfs, graph_from_gdfs, create_manhattan_cartography, + to_folium_map, ) @@ -218,5 +220,64 @@ def test_rectangular_grid(self): assert len(edges) == expected_edges +class TestToFoliumMap: + """Tests for to_folium_map function.""" + + @pytest.fixture + def sample_graph(self): + """Create a sample graph for testing.""" + edges, nodes = create_manhattan_cartography(n_x=3, n_y=3) + return graph_from_gdfs(edges, nodes) + + def test_returns_folium_map(self, sample_graph): + """Test that the function returns a folium.Map object.""" + result = to_folium_map(sample_graph) + assert isinstance(result, folium.Map) + + def test_edges_only(self, sample_graph): + """Test visualization with edges only (default).""" + result = to_folium_map(sample_graph, which="edges") + assert isinstance(result, folium.Map) + # Check that the map has children (the edges) + assert len(result._children) > 0 + + def test_nodes_only(self, sample_graph): + """Test visualization with nodes only.""" + result = to_folium_map(sample_graph, which="nodes") + assert isinstance(result, folium.Map) + assert len(result._children) > 0 + + def test_both_edges_and_nodes(self, sample_graph): + """Test visualization with both edges and nodes.""" + result = to_folium_map(sample_graph, which="both") + assert isinstance(result, folium.Map) + # Should have more children than edges-only or nodes-only + edges_only = to_folium_map(sample_graph, which="edges") + nodes_only = to_folium_map(sample_graph, which="nodes") + # 'both' should have children from edges and nodes combined + # (minus the base tile layer which is common) + assert len(result._children) >= len(edges_only._children) + assert len(result._children) >= len(nodes_only._children) + + def test_map_center_location(self, sample_graph): + """Test that the map is centered correctly.""" + result = to_folium_map(sample_graph) + # The map should be centered around the mean of node coordinates + # For a Manhattan grid centered at (0, 0), the center should be near (0, 0) + location = result.location + assert location is not None + assert len(location) == 2 + # Check that location is reasonable (near 0,0 for default manhattan grid) + assert -1 < location[0] < 1 # latitude + assert -1 < location[1] < 1 # longitude + + def test_default_which_parameter(self, sample_graph): + """Test that default 'which' parameter is 'edges'.""" + default_result = to_folium_map(sample_graph) + edges_result = to_folium_map(sample_graph, which="edges") + # Both should produce maps with the same number of children + assert len(default_result._children) == len(edges_result._children) + + if __name__ == "__main__": pytest.main([__file__, "-v"]) From bfbc552d5e5b0d1377f73d8d601cf46fe70bd40b Mon Sep 17 00:00:00 2001 From: Grufoony Date: Wed, 21 Jan 2026 15:30:05 +0100 Subject: [PATCH 3/5] Update requirements --- requirements.txt | 3 +-- setup.py | 1 + 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 62254191..da1488b9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,8 +5,7 @@ networkx numpy osmnx pandas -Pillow seaborn tqdm -opencv-python shapely +folium diff --git a/setup.py b/setup.py index 7294397e..693062a9 100644 --- a/setup.py +++ b/setup.py @@ -521,5 +521,6 @@ def run_stubgen(self): "numpy", "geopandas", "shapely", + "folium", ], ) From 39c0ef1db8ee9fdb8e351af9a65dbd7ca33a74ec Mon Sep 17 00:00:00 2001 From: Grufoony Date: Wed, 21 Jan 2026 15:40:29 +0100 Subject: [PATCH 4/5] Fix --- src/dsf/python/cartography.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/dsf/python/cartography.py b/src/dsf/python/cartography.py index 1083997b..e318c7c5 100644 --- a/src/dsf/python/cartography.py +++ b/src/dsf/python/cartography.py @@ -466,8 +466,8 @@ def to_folium_map( """ # Compute mean latitude and longitude for centering the map - mean_lat = np.mean([data["y"] for _, data in G.nodes(data=True)]) - mean_lon = np.mean([data["x"] for _, data in G.nodes(data=True)]) + mean_lat = np.mean([data["geometry"].y for _, data in G.nodes(data=True)]) + mean_lon = np.mean([data["geometry"].x for _, data in G.nodes(data=True)]) folium_map = folium.Map(location=[mean_lat, mean_lon], zoom_start=13) if which in ("edges", "both"): @@ -486,7 +486,7 @@ def to_folium_map( # Add nodes to the map for _, data in G.nodes(data=True): folium.CircleMarker( - location=(data["y"], data["x"]), + location=(data["geometry"].y, data["geometry"].x), radius=5, color="red", fill=True, From aaef5f9fb1a5d0082b836b4b36d22f67b078edcf Mon Sep 17 00:00:00 2001 From: Grufoony Date: Wed, 21 Jan 2026 15:47:08 +0100 Subject: [PATCH 5/5] Bump version --- src/dsf/dsf.hpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dsf/dsf.hpp b/src/dsf/dsf.hpp index 0aa55faf..0260f768 100644 --- a/src/dsf/dsf.hpp +++ b/src/dsf/dsf.hpp @@ -6,7 +6,7 @@ static constexpr uint8_t DSF_VERSION_MAJOR = 4; static constexpr uint8_t DSF_VERSION_MINOR = 7; -static constexpr uint8_t DSF_VERSION_PATCH = 2; +static constexpr uint8_t DSF_VERSION_PATCH = 3; static auto const DSF_VERSION = std::format("{}.{}.{}", DSF_VERSION_MAJOR, DSF_VERSION_MINOR, DSF_VERSION_PATCH);