Morphological Graphs from Overture Maps and OpenStreetMap¶
This notebook builds a morphological graph of Liverpool city centre with city2graph, using Overture Maps data, and repeats the workflow with OpenStreetMap data at the end.
What is a morphological graph?¶
A morphological graph is a heterogeneous graph in which:
- Nodes represent both movement space (street segments) and place space (enclosed tessellated areas).
- Edges represent three spatial relationships:
- Place-to-place: neighbouring place spaces
- Movement-to-movement: connectivity in the street network (dual graph)
- Place-to-movement: interfaces between place spaces and streets
Together these capture urban form (plots and buildings) and the network that connects it in a single structure.
1. Setup and Dependencies¶
# Core libraries
import numpy as np
import geopandas as gpd
import matplotlib.pyplot as plt
import contextily as cx
from shapely import Point
# city2graph for morphological graph analysis
import city2graph as c2g
# Configure matplotlib for high-quality visualizations
plt.rcParams['figure.figsize'] = (14, 10)
plt.rcParams['figure.dpi'] = 100
plt.rcParams['font.size'] = 11
plt.style.use('ggplot')
print("Dependencies loaded successfully")
print(f"city2graph version: {c2g.__version__}")
print(f"geopandas version: {gpd.__version__}")
Dependencies loaded successfully city2graph version: 0.3.0 geopandas version: 1.1.3
2. Load Data from Overture Maps¶
The inputs for Liverpool city centre are building footprints (place space), road segments (movement network), and connectors (junction points), all from Overture Maps.
# Download data from Overture Maps (uncomment to download fresh data)
c2g.load_overture_data(
place_name="Liverpool, UK",
types=["segment", "building", "connector"],
output_dir="./data/overturemaps",
prefix="liverpool_",
save_to_file=True,
return_data=False,
)
{} # Load the downloaded GeoJSON files
buildings_gdf = gpd.read_file("./data/overturemaps/liverpool_building.geojson")
segments_gdf = gpd.read_file("./data/overturemaps/liverpool_segment.geojson")
connectors_gdf = gpd.read_file("./data/overturemaps/liverpool_connector.geojson")
# Convert to British National Grid (EPSG:27700) for accurate distance calculations
buildings_gdf = buildings_gdf.to_crs(epsg=27700)
segments_gdf = segments_gdf.to_crs(epsg=27700)
connectors_gdf = connectors_gdf.to_crs(epsg=27700)
print("Data loaded successfully")
print("Dataset summary:")
print(f" - Buildings: {len(buildings_gdf):,}")
print(f" - Road segments: {len(segments_gdf):,}")
print(f" - Connectors: {len(connectors_gdf):,}")
print(f" - CRS: {buildings_gdf.crs}")
/Users/yutasato/Projects/Liverpool/city2graph/.venv/lib/python3.13/site-packages/geopandas/io/file.py:576: UserWarning: Could not parse column 'subclass_rules' as JSON; leaving as string return pyogrio.read_dataframe(path_or_bytes, bbox=bbox, **kwargs) /Users/yutasato/Projects/Liverpool/city2graph/.venv/lib/python3.13/site-packages/geopandas/io/file.py:576: UserWarning: Could not parse column 'width_rules' as JSON; leaving as string return pyogrio.read_dataframe(path_or_bytes, bbox=bbox, **kwargs)
Data loaded successfully Dataset summary: - Buildings: 107,228 - Road segments: 39,490 - Connectors: 50,971 - CRS: EPSG:27700
3. Street Network Processing¶
The raw segments need preparation before tessellation: keeping only road segments (excluding pedestrian paths, railways, and so on) and resolving bridges and tunnels. The resulting barrier_geometry column holds the processed geometries that act as spatial barriers for the tessellation.
# Filter to keep only road segments (excluding pedestrian paths, railways, etc.)
segments_gdf = segments_gdf[segments_gdf["subtype"] == "road"].copy()
# Process segments to handle bridges/tunnels and create proper spatial barriers
segments_gdf = c2g.process_overture_segments(
segments_gdf=segments_gdf,
get_barriers=True,
connectors_gdf=connectors_gdf
)
print(f"Processed {len(segments_gdf)} road segments")
print("Barrier geometries created for tessellation")
# Check the geometry types in the barrier_geometry column
geometry_types = segments_gdf["barrier_geometry"].geom_type.value_counts()
print("\nBarrier geometry types:")
for geom_type, count in geometry_types.items():
print(f" - {geom_type}: {count:,}")
Processed 67567 road segments Barrier geometries created for tessellation Barrier geometry types: - LineString: 66,486 - MultiLineString: 573
These segments can be converted to a homogeneous graph with segments_to_graph, which works for any segments GeoDataFrame, not only one derived from Overture Maps.
segments_G = c2g.segments_to_graph(segments_gdf, as_nx=True)
Removed 14 invalid geometries
c2g.plot_graph(segments_G)
<Axes: >
4. Create the Morphological Graph¶
morphological_graph tessellates space using the street segments as barriers, then extracts the three relationships: adjacency between place spaces, street-network connectivity, and the interfaces between the two. The result is one unified graph of movement and place space.
# Define center point for the analysis area (Liverpool city centre)
center_point = gpd.GeoSeries([Point(-2.9879004, 53.4062724)], crs='EPSG:4326').to_crs(epsg=27700)
# Create the morphological graph
morpho_nodes, morpho_edges = c2g.morphological_graph(
buildings_gdf=buildings_gdf,
segments_gdf=segments_gdf,
center_point=center_point,
distance=500, # Analysis radius in meters
clipping_buffer=300, # Buffer for edge effects
primary_barrier_col='barrier_geometry',
contiguity="queen", # Adjacency rule for tessellation
keep_buildings=True, # Preserve building geometries
)
Removed 14 invalid geometries /Users/yutasato/Projects/Liverpool/city2graph/.venv/lib/python3.13/site-packages/libpysal/weights/contiguity.py:347: UserWarning: The weights matrix is not fully connected: There are 3 disconnected components. There are 2 islands with ids: 21_21279, 21_21957. W.__init__(self, neighbors, ids=ids, **kw)
print(f"Node types: {list(morpho_nodes.keys())}")
print(f"Edge types: {list(morpho_edges.keys())}")
print(f"Place spaces: {len(morpho_nodes['place']):,}")
print(f"Movement spaces: {len(morpho_nodes['movement']):,}")
for edge_type, edge_gdf in morpho_edges.items():
print(f" • {edge_type}: {len(edge_gdf):,} connections")
Node types: ['place', 'movement']
Edge types: [('place', 'touched_to', 'place'), ('movement', 'connected_to', 'movement'), ('place', 'faced_to', 'movement')]
Place spaces: 1,337
Movement spaces: 802
• ('place', 'touched_to', 'place'): 613 connections
• ('movement', 'connected_to', 'movement'): 1,478 connections
• ('place', 'faced_to', 'movement'): 2,496 connections
morpho_nodes["place"].head()
| geometry | enclosure_index | id | version | sources | level | subtype | class | height | names | ... | min_floor | num_floors_underground | roof_material | roof_shape | roof_direction | roof_orientation | roof_color | roof_height | building_geometry | tessellation_geometry | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| place_id | |||||||||||||||||||||
| 3_21872 | POINT (334873.498 390439.91) | 3 | cfe17f61-252a-4796-b319-3cc062902869 | 1.0 | [{'property': '', 'dataset': 'OpenStreetMap', ... | NaN | NaN | NaN | 5.0 | NaN | ... | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | POLYGON ((334801.678 390395.851, 334803.501 39... | POLYGON ((334826.282 390394.268, 334826.121 39... |
| 3_21872 | POINT (334873.498 390439.91) | 3 | 76359bbc-484e-4182-b2aa-3f3e2231deda | 1.0 | [{'property': '', 'dataset': 'OpenStreetMap', ... | 4.0 | NaN | NaN | 125.0 | {'primary': 'St Johns beacon', 'common': None,... | ... | NaN | NaN | NaN | flat | NaN | NaN | #464875 | NaN | POLYGON ((334810.844 390396.615, 334811.403 39... | POLYGON ((334826.282 390394.268, 334826.121 39... |
| 3_21872 | POINT (334873.498 390439.91) | 3 | f5bf15d8-3547-41aa-a9db-abaa10435702 | 1.0 | [{'property': '', 'dataset': 'OpenStreetMap', ... | 3.0 | NaN | NaN | 110.0 | {'primary': "Saint John's Beacon", 'common': N... | ... | NaN | NaN | NaN | pyramidal | NaN | NaN | #464875 | 3.0 | POLYGON ((334804.854 390402.461, 334806.725 39... | POLYGON ((334826.282 390394.268, 334826.121 39... |
| 3_21872 | POINT (334873.498 390439.91) | 3 | 806b8ff0-ee54-47f4-af82-fef9069e18cd | 1.0 | [{'property': '', 'dataset': 'OpenStreetMap', ... | NaN | entertainment | NaN | NaN | {'primary': 'The Playhouse', 'common': None, '... | ... | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | POLYGON ((334782.286 390434.238, 334803.535 39... | POLYGON ((334826.282 390394.268, 334826.121 39... |
| 3_21872 | POINT (334873.498 390439.91) | 3 | e698148b-d3f8-4141-80be-73c252b18114 | 3.0 | [{'property': '', 'dataset': 'OpenStreetMap', ... | 1.0 | NaN | NaN | 21.0 | {'primary': 'Holiday Inn Liverpool City Centre... | ... | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | POLYGON ((334934.651 390448.532, 334945.788 39... | POLYGON ((334826.282 390394.268, 334826.121 39... |
5 rows × 26 columns
morpho_nodes["movement"].head()
| id | version | sources | subtype | class | names | connectors | routes | subclass_rules | access_restrictions | ... | road_flags | speed_limits | width_rules | subclass | geometry | split_from | split_to | length | barrier_geometry | segment_geometry | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| movement_id | |||||||||||||||||||||
| 5925 | e0bde33d-2b9c-4c2d-aa04-a97282b77f3c | 1 | [{'property': '', 'dataset': 'OpenStreetMap', ... | road | footway | {'primary': 'Silkhouse Lane', 'common': None, ... | [{'connector_id': 'cb80addf-b40f-4a2c-b934-9ea... | NaN | NaN | NaN | ... | NaN | NaN | NaN | NaN | POINT (334150.16 390644.48) | NaN | NaN | 61.861764 | LINESTRING (334169.374 390620.261, 334163.126 ... | LINESTRING (334169.374 390620.261, 334163.126 ... |
| 6003 | 71f65a3f-ac2b-4a9f-965b-7b906e01a02d_2 | 1 | [{'property': '', 'dataset': 'OpenStreetMap', ... | road | unclassified | {'primary': 'George Street', 'common': None, '... | [{'connector_id': 'd1087f24-d203-40be-8755-bc6... | NaN | NaN | NaN | ... | NaN | NaN | NaN | NaN | POINT (334125.974 390664.938) | 0.401639 | 0.499762 | 11.245853 | LINESTRING (334121.486 390661.551, 334121.486 ... | LINESTRING (334121.486 390661.551, 334121.486 ... |
| 6004 | 71f65a3f-ac2b-4a9f-965b-7b906e01a02d_3 | 1 | [{'property': '', 'dataset': 'OpenStreetMap', ... | road | unclassified | {'primary': 'George Street', 'common': None, '... | [{'connector_id': 'd1087f24-d203-40be-8755-bc6... | NaN | NaN | NaN | ... | NaN | NaN | NaN | NaN | POINT (334152.375 390686.808) | 0.499762 | 1.000000 | 57.332357 | LINESTRING (334130.463 390668.325, 334130.463 ... | LINESTRING (334130.463 390668.325, 334130.463 ... |
| 5978 | 72f5861f-12e2-469b-9a1e-ac1255565df4_2 | 1 | [{'property': '', 'dataset': 'OpenStreetMap', ... | road | footway | NaN | [{'connector_id': 'd3c8c209-bc3b-4afb-b781-dbc... | NaN | NaN | NaN | ... | [{'values': ['is_tunnel'], 'between': None}] | NaN | NaN | NaN | POINT (334253.291 390669.572) | 0.472509 | 1.000000 | 4.051704 | None | LINESTRING (334254.06 390667.698, 334252.523 3... |
| 6006 | 8875ee79-3883-4dac-820e-178a84e73d32 | 1 | [{'property': '', 'dataset': 'OpenStreetMap', ... | road | steps | NaN | [{'connector_id': '9ef168e1-b984-4916-abde-fad... | NaN | NaN | NaN | ... | [{'values': ['is_tunnel'], 'between': None}] | NaN | NaN | NaN | POINT (334248.53 390681.222) | NaN | NaN | 21.117956 | None | LINESTRING (334252.523 390671.447, 334244.537 ... |
5 rows × 25 columns
morpho_edges[('movement', 'connected_to', 'movement')].head()
| geometry | ||
|---|---|---|
| from_movement_id | to_movement_id | |
| 3652 | 3660 | LINESTRING (334159.774 390163.587, 334198.911 ... |
| 4813 | LINESTRING (334159.774 390163.587, 334148.816 ... | |
| 3653 | 3654 | LINESTRING (334106.02 390173.147, 334109.83 39... |
| 3658 | LINESTRING (334106.02 390173.147, 334115.312 3... | |
| 3673 | LINESTRING (334106.02 390173.147, 334095.885 3... |
morpho_edges[('place', 'faced_to', 'movement')].head()
| geometry | ||
|---|---|---|
| place_id | movement_id | |
| 41_23139 | 5925 | LINESTRING (334124.632 390556.767, 334150.16 3... |
| 346_23142 | 5925 | LINESTRING (334135.097 390624.685, 334150.16 3... |
| 348_23143 | 5925 | LINESTRING (334170.822 390641.238, 334150.16 3... |
| 348_23144 | 5925 | LINESTRING (334152.116 390657.487, 334150.16 3... |
| 244_23141 | 5925 | LINESTRING (334196.481 390627.064, 334150.16 3... |
morpho_edges[('place', 'touched_to', 'place')].head()
| weight | geometry | enclosure_index | ||
|---|---|---|---|---|
| from_place_id | to_place_id | |||
| 3_21872 | 3_21892 | 32.241903 | LINESTRING (334873.498 390439.91, 334845.529 3... | 3 |
| 3_21889 | 94.689168 | LINESTRING (334873.498 390439.91, 334783.071 3... | 3 | |
| 3_21886 | 74.699474 | LINESTRING (334873.498 390439.91, 334819.742 3... | 3 | |
| 3_21881 | 101.010127 | LINESTRING (334873.498 390439.91, 334774.745 3... | 3 | |
| 3_21888 | 62.819823 | LINESTRING (334873.498 390439.91, 334823.576 3... | 3 |
morpho_nodes["movement"]
| id | version | sources | subtype | class | names | connectors | routes | subclass_rules | access_restrictions | ... | road_flags | speed_limits | width_rules | subclass | geometry | split_from | split_to | length | barrier_geometry | segment_geometry | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| movement_id | |||||||||||||||||||||
| 5925 | e0bde33d-2b9c-4c2d-aa04-a97282b77f3c | 1 | [{'property': '', 'dataset': 'OpenStreetMap', ... | road | footway | {'primary': 'Silkhouse Lane', 'common': None, ... | [{'connector_id': 'cb80addf-b40f-4a2c-b934-9ea... | NaN | NaN | NaN | ... | NaN | NaN | NaN | NaN | POINT (334150.16 390644.48) | NaN | NaN | 61.861764 | LINESTRING (334169.374 390620.261, 334163.126 ... | LINESTRING (334169.374 390620.261, 334163.126 ... |
| 6003 | 71f65a3f-ac2b-4a9f-965b-7b906e01a02d_2 | 1 | [{'property': '', 'dataset': 'OpenStreetMap', ... | road | unclassified | {'primary': 'George Street', 'common': None, '... | [{'connector_id': 'd1087f24-d203-40be-8755-bc6... | NaN | NaN | NaN | ... | NaN | NaN | NaN | NaN | POINT (334125.974 390664.938) | 0.401639 | 0.499762 | 11.245853 | LINESTRING (334121.486 390661.551, 334121.486 ... | LINESTRING (334121.486 390661.551, 334121.486 ... |
| 6004 | 71f65a3f-ac2b-4a9f-965b-7b906e01a02d_3 | 1 | [{'property': '', 'dataset': 'OpenStreetMap', ... | road | unclassified | {'primary': 'George Street', 'common': None, '... | [{'connector_id': 'd1087f24-d203-40be-8755-bc6... | NaN | NaN | NaN | ... | NaN | NaN | NaN | NaN | POINT (334152.375 390686.808) | 0.499762 | 1.000000 | 57.332357 | LINESTRING (334130.463 390668.325, 334130.463 ... | LINESTRING (334130.463 390668.325, 334130.463 ... |
| 5978 | 72f5861f-12e2-469b-9a1e-ac1255565df4_2 | 1 | [{'property': '', 'dataset': 'OpenStreetMap', ... | road | footway | NaN | [{'connector_id': 'd3c8c209-bc3b-4afb-b781-dbc... | NaN | NaN | NaN | ... | [{'values': ['is_tunnel'], 'between': None}] | NaN | NaN | NaN | POINT (334253.291 390669.572) | 0.472509 | 1.000000 | 4.051704 | None | LINESTRING (334254.06 390667.698, 334252.523 3... |
| 6006 | 8875ee79-3883-4dac-820e-178a84e73d32 | 1 | [{'property': '', 'dataset': 'OpenStreetMap', ... | road | steps | NaN | [{'connector_id': '9ef168e1-b984-4916-abde-fad... | NaN | NaN | NaN | ... | [{'values': ['is_tunnel'], 'between': None}] | NaN | NaN | NaN | POINT (334248.53 390681.222) | NaN | NaN | 21.117956 | None | LINESTRING (334252.523 390671.447, 334244.537 ... |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 5985 | 15129c3c-39b0-48f5-a50f-fed638f77c5c_1 | 1 | [{'property': '', 'dataset': 'OpenStreetMap', ... | road | footway | NaN | [{'connector_id': 'c8c6db5f-fd5f-4081-8a40-b04... | NaN | NaN | NaN | ... | [{'values': ['is_tunnel'], 'between': None}] | NaN | NaN | NaN | POINT (334311.927 390679.223) | 0.000000 | 0.161856 | 6.072757 | None | LINESTRING (334309.954 390681.532, 334313.9 39... |
| 5993 | fa41a3f0-fbe0-4dd4-8cb7-ca4a0cff5bd1_2 | 2 | [{'property': '', 'dataset': 'OpenStreetMap', ... | road | service | NaN | [{'connector_id': '3396b058-f2f8-4319-985d-2c8... | NaN | NaN | NaN | ... | NaN | NaN | NaN | NaN | POINT (334382.856 390682.399) | 0.088272 | 1.000000 | 7.534685 | LINESTRING (334380.166 390685.037, 334380.416 ... | LINESTRING (334380.166 390685.037, 334380.416 ... |
| 5996 | 1b0e7a0c-9d5b-490a-aeed-a1af1cb8829c_1 | 1 | [{'property': '', 'dataset': 'OpenStreetMap', ... | road | unclassified | {'primary': 'Hale Street', 'common': None, 'ru... | [{'connector_id': '5ebb5ed2-a348-407d-b350-9b9... | NaN | NaN | NaN | ... | NaN | NaN | NaN | NaN | POINT (334397.203 390690.514) | 0.000000 | 0.606566 | 31.720834 | LINESTRING (334408.861 390701.268, 334385.545 ... | LINESTRING (334408.861 390701.268, 334385.545 ... |
| 5999 | 393427b8-f682-47f0-af65-696289933c29 | 1 | [{'property': '', 'dataset': 'OpenStreetMap', ... | road | steps | NaN | [{'connector_id': '7f59dc06-b5cc-4986-9cd3-81c... | NaN | NaN | NaN | ... | [{'values': ['is_indoor'], 'between': None}] | NaN | NaN | NaN | POINT (334127.652 390653.109) | NaN | NaN | 13.068364 | LINESTRING (334131.541 390647.859, 334123.762 ... | LINESTRING (334131.541 390647.859, 334123.762 ... |
| 6001 | 371a7be4-a121-46c3-8412-2324f56e3e2e | 1 | [{'property': '', 'dataset': 'OpenStreetMap', ... | road | footway | NaN | [{'connector_id': '22b727be-260b-4867-a4c8-fec... | NaN | NaN | NaN | ... | NaN | NaN | NaN | NaN | POINT (334122.624 390659.956) | NaN | NaN | 3.920101 | LINESTRING (334123.762 390658.36, 334121.486 3... | LINESTRING (334123.762 390658.36, 334121.486 3... |
802 rows × 25 columns
fig, ax = plt.subplots(figsize=(14, 12), facecolor='#f9f9f9')
morpho_nodes["place"]["building_geometry"].plot(ax=ax, color='#e0e0e0', edgecolor='#c0c0c0', linewidth=0.3, alpha=0.7)
morpho_nodes["movement"]["segment_geometry"].plot(ax=ax, color='#404040', linewidth=0.7, alpha=0.6)
c2g.plot_graph(
nodes=morpho_nodes,
edges=morpho_edges,
subplots=False,
ax=ax,
bgcolor="#f9f9f9",
labelcolor="#000000",
node_color={'place': 'red', 'movement': 'blue'},
edge_color={
('place', 'touched_to', 'place'): '#B22222',
('movement', 'connected_to', 'movement'): '#0000FF',
('place', 'faced_to', 'movement'): '#7B68EE',
},
markersize=10,
linewidth=0.2,
)
<Axes: >
5. Convert to Graph Formats for Machine Learning¶
The heterogeneous graph moves freely between GeoDataFrames (spatial analysis and plotting), NetworkX (graph algorithms), and PyTorch Geometric (graph deep learning). The following cells show the common conversion patterns and quick checks.
# Check for PyTorch Geometric availability
import torch
try:
import torch_geometric
HAS_TORCH_GEOMETRIC = True
print("PyTorch Geometric imported successfully")
print(f" PyTorch version: {torch.__version__}")
print(f" PyTorch Geometric version: {torch_geometric.__version__}")
print("Ready for graph representation learning")
except ImportError:
HAS_TORCH_GEOMETRIC = False
print("PyTorch Geometric is not installed")
print("Install with: pip install torch-geometric")
print("Graph conversion examples will continue with available backends")
PyTorch Geometric imported successfully PyTorch version: 2.10.0 PyTorch Geometric version: 2.7.0 Ready for graph representation learning
5.1 Homogeneous Graph: Place Space Network¶
A homogeneous graph of tessellation cells and their adjacency captures the spatial structure of plots on its own.
# Compute spatial features for place spaces (tessellation cells)
morpho_nodes["place"]["area"] = morpho_nodes["place"].geometry.area
morpho_nodes["place"]["perimeter"] = morpho_nodes["place"].geometry.length
morpho_nodes["place"]["compactness"] = (
4 * np.pi * morpho_nodes["place"]["area"] / (morpho_nodes["place"]["perimeter"] ** 2)
)
print("Computed spatial features for place spaces:")
feature_stats = morpho_nodes["place"][['area', 'perimeter', 'compactness']].describe()
print(feature_stats.round(2))
# Set proper index names for graph conversion
morpho_nodes["place"].index.name = "tess_id"
morpho_edges[('place', 'touched_to', 'place')].index.names = ['from_place_id', 'to_place_id']
print("\nFeatures computed and indices configured")
Computed spatial features for place spaces:
area perimeter compactness
count 1337.0 1337.0 0.0
mean 0.0 0.0 NaN
std 0.0 0.0 NaN
min 0.0 0.0 NaN
25% 0.0 0.0 NaN
50% 0.0 0.0 NaN
75% 0.0 0.0 NaN
max 0.0 0.0 NaN
Features computed and indices configured
if HAS_TORCH_GEOMETRIC:
# Create homogeneous graph of place spaces
place_graph = c2g.gdf_to_pyg(
nodes=morpho_nodes["place"],
edges=morpho_edges[('place', 'touched_to', 'place')],
node_feature_cols=['area', 'perimeter', 'compactness'],
edge_feature_cols=None
)
print("Created homogeneous graph:")
print(f" - Nodes: {place_graph.num_nodes:,}")
print(f" - Edges: {place_graph.num_edges:,}")
print(f" - Node features: {place_graph.x.shape[1]} dimensions")
print(f" - Feature tensor shape: {place_graph.x.shape}")
print("\nGraph is ready for training")
else:
print("Skipping PyTorch Geometric conversion (not installed)")
place_graph = None
Created homogeneous graph: - Nodes: 1,337 - Edges: 613 - Node features: 3 dimensions - Feature tensor shape: torch.Size([1337, 3]) Graph is ready for training
Conversion Checks¶
Running the pipeline again on a full heterogeneous graph validates that the format conversions preserve the expected structure.
# Define center point for the analysis area (Liverpool city centre)
center_point = gpd.GeoSeries([Point(-2.9879004, 53.4062724)], crs='EPSG:4326').to_crs(epsg=27700)
# Create the morphological graph
print("Creating morphological graph...")
morpho_nodes, morpho_edges = c2g.morphological_graph(
buildings_gdf=buildings_gdf,
segments_gdf=segments_gdf,
center_point=center_point,
distance=500, # Analysis radius in meters
clipping_buffer=300, # Buffer for edge effects
primary_barrier_col='barrier_geometry',
contiguity="queen", # Adjacency rule for tessellation
keep_buildings=True, # Preserve building geometries
)
print("Morphological graph created successfully")
print("Network summary:")
print(f" - Node types: {list(morpho_nodes.keys())}")
print(f" - Edge types: {list(morpho_edges.keys())}")
print(f" - Place spaces: {len(morpho_nodes['place']):,}")
print(f" - Movement spaces: {len(morpho_nodes['movement']):,}")
for edge_type, edge_gdf in morpho_edges.items():
print(f" - {edge_type}: {len(edge_gdf):,} connections")
Removed 14 invalid geometries
Creating morphological graph...
Morphological graph created successfully
Network summary:
- Node types: ['place', 'movement']
- Edge types: [('place', 'touched_to', 'place'), ('movement', 'connected_to', 'movement'), ('place', 'faced_to', 'movement')]
- Place spaces: 1,337
- Movement spaces: 802
- ('place', 'touched_to', 'place'): 613 connections
- ('movement', 'connected_to', 'movement'): 1,478 connections
- ('place', 'faced_to', 'movement'): 2,496 connections
/Users/yutasato/Projects/Liverpool/city2graph/.venv/lib/python3.13/site-packages/libpysal/weights/contiguity.py:347: UserWarning: The weights matrix is not fully connected: There are 3 disconnected components. There are 2 islands with ids: 21_21279, 21_21957. W.__init__(self, neighbors, ids=ids, **kw)
# Recompute node features after rebuilding morpho_nodes
morpho_nodes["place"]["area"] = morpho_nodes["place"].geometry.area
morpho_nodes["place"]["perimeter"] = morpho_nodes["place"].geometry.length
morpho_nodes["place"]["compactness"] = (
4 * np.pi * morpho_nodes["place"]["area"] / (morpho_nodes["place"]["perimeter"] ** 2)
)
morpho_nodes["movement"]["length"] = morpho_nodes["movement"].geometry.length
# Keep index names explicit so downstream conversions preserve structure
morpho_nodes["place"].index.name = "place_id"
morpho_nodes["movement"].index.name = "movement_id"
morpho_edges[('place', 'touched_to', 'place')].index.names = ['from_place_id', 'to_place_id']
morpho_edges[('movement', 'connected_to', 'movement')].index.names = ['from_movement_id', 'to_movement_id']
morpho_edges[('place', 'faced_to', 'movement')].index.names = ['from_place_id', 'to_movement_id']
print("Computed spatial features for place and movement nodes")
print("Place feature columns:", ["area", "perimeter", "compactness"])
print("Movement feature columns:", ["length"])
Computed spatial features for place and movement nodes Place feature columns: ['area', 'perimeter', 'compactness'] Movement feature columns: ['length']
if HAS_TORCH_GEOMETRIC:
# Create heterogeneous graph with all morphological relationships
hetero_graph = c2g.gdf_to_pyg(
morpho_nodes,
morpho_edges,
node_feature_cols={
"place": ['area', 'perimeter', 'compactness'],
"movement": ['length']
}
)
print("Created heterogeneous morphological graph:")
print("Node types and counts:")
for node_type in hetero_graph.node_types:
num_nodes = hetero_graph[node_type].x.shape[0]
num_features = hetero_graph[node_type].x.shape[1]
print(f" - {node_type}: {num_nodes:,} nodes, {num_features} features")
print("Edge types and counts:")
for edge_type in hetero_graph.edge_types:
num_edges = hetero_graph[edge_type].edge_index.shape[1]
print(f" - {edge_type}: {num_edges:,} edges")
print("\nComplete morphological graph is ready for heterogeneous GNN workflows")
else:
print("Skipping PyTorch Geometric conversion (not installed)")
hetero_graph = None
Created heterogeneous morphological graph:
Node types and counts:
- place: 1,337 nodes, 3 features
- movement: 802 nodes, 1 features
Edge types and counts:
- ('place', 'touched_to', 'place'): 613 edges
- ('movement', 'connected_to', 'movement'): 1,478 edges
- ('place', 'faced_to', 'movement'): 2,496 edges
Complete morphological graph is ready for heterogeneous GNN workflows
hetero_graph
HeteroData(
crs=EPSG:27700,
graph_metadata=<city2graph.base.GraphMetadata object at 0x13392baf0>,
place={
x=[1337, 3],
pos=[1337, 2],
},
movement={
x=[802, 1],
pos=[802, 2],
},
(place, touched_to, place)={
edge_index=[2, 613],
edge_attr=[613, 0],
},
(movement, connected_to, movement)={
edge_index=[2, 1478],
edge_attr=[1478, 0],
},
(place, faced_to, movement)={
edge_index=[2, 2496],
edge_attr=[2496, 0],
}
) 5.2 Using NetworkX¶
Conversion to NetworkX opens up its ecosystem of graph algorithms.
# Convert morphological graph to PyG and NetworkX
morpho_graph = c2g.gdf_to_pyg(
morpho_nodes,
morpho_edges,
node_feature_cols={
"place": ['area', 'perimeter', 'compactness'],
"movement": ['length']
},
)
# Convert morphological graph to NetworkX for traditional graph analysis
G_morpho = c2g.gdf_to_nx(morpho_nodes, morpho_edges, keep_geom=True)
print("Converted to NetworkX:")
print(f" - Total nodes: {G_morpho.number_of_nodes():,}")
print(f" - Total edges: {G_morpho.number_of_edges():,}")
print(f" - Graph type: {'Directed' if G_morpho.is_directed() else 'Undirected'}")
# Check heterogeneity and inspect attributes
has_node_types = any('node_type' in data for _, data in G_morpho.nodes(data=True))
print(f" - Heterogeneous: {'Yes' if has_node_types else 'No'}")
sample_node_id, sample_node_data = next(iter(G_morpho.nodes(data=True)))
non_place_keys = [k for k in sample_node_data.keys() if not k.startswith('_')]
print(f" - Sample node id: {sample_node_id}")
print(f" - Sample node attributes: {non_place_keys}")
if has_node_types:
from collections import Counter
node_types = [data.get('node_type', 'unknown') for _, data in G_morpho.nodes(data=True)]
type_counts = Counter(node_types)
print(" - Node type distribution:")
for node_type, count in type_counts.items():
print(f" - {node_type}: {count:,}")
print("\nNetworkX graph is ready for analysis")
Converted to NetworkX:
- Total nodes: 2,139
- Total edges: 4,587
- Graph type: Undirected
- Heterogeneous: Yes
- Sample node id: 0
- Sample node attributes: ['geometry', 'enclosure_index', 'id', 'version', 'sources', 'level', 'subtype', 'class', 'height', 'names', 'has_parts', 'is_underground', 'min_height', 'num_floors', 'facade_color', 'facade_material', 'min_floor', 'num_floors_underground', 'roof_material', 'roof_shape', 'roof_direction', 'roof_orientation', 'roof_color', 'roof_height', 'building_geometry', 'tessellation_geometry', 'area', 'perimeter', 'compactness', 'node_type', 'pos']
- Node type distribution:
- place: 1,337
- movement: 802
NetworkX graph is ready for analysis
6. OpenStreetMap¶
For comparison, the same workflow runs on data pulled from OpenStreetMap with OSMnx.
import osmnx as ox
# Download and create a street network graph for Liverpool, UK
G_osmnx = ox.graph_from_place("Liverpool, UK", network_type="all")
print("OSMnx graph created for Liverpool, UK")
print(f" - Nodes: {len(G_osmnx.nodes):,}")
print(f" - Edges: {len(G_osmnx.edges):,}")
OSMnx graph created for Liverpool, UK - Nodes: 45,081 - Edges: 114,978
# Download POIs (amenities, shops, etc.) in Liverpool, UK
tags = {"building": True}
osm_buildings = ox.features_from_place("Liverpool, UK", tags)
print(f"Retrieved {len(osm_buildings):,} buildings from OSMnx")
osm_buildings.head()
Retrieved 77,492 buildings from OSMnx
| geometry | building | building:material | building:use | name | emergency | addr:street | shop | website | addr:city | ... | service:bicycle:parts | service:bicycle:pump | service:bicycle:second_hand | service:bicycle:spare_parts | type | school:trust | school:trust:name | school:trust:type | manager | last_checked | ||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| element | id | |||||||||||||||||||||
| node | 130190976 | POINT (-2.92799 53.37127) | yes | brick | religious | Saint Anne's Parish Hall - 1902 | NaN | NaN | NaN | NaN | NaN | ... | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
| 252558860 | POINT (-2.91785 53.396) | residential | NaN | NaN | Bradbury House | NaN | NaN | NaN | NaN | NaN | ... | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | |
| 299317179 | POINT (-2.97904 53.39175) | yes | NaN | NaN | Toxteth Ambulance Station | ambulance_station | NaN | NaN | NaN | NaN | ... | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | |
| 1563152870 | POINT (-2.91166 53.41248) | manufacture | NaN | NaN | Tom Williams Cake Factory | NaN | Broad Green Road | pastry | http://www.cakefactory.co.uk/ | NaN | ... | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | |
| 1669223582 | POINT (-2.96855 53.40601) | yes | NaN | NaN | ALL (Active Learning Laboratory) | NaN | NaN | NaN | NaN | NaN | ... | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
5 rows × 378 columns
To convert the nx.MultiGraph directly into torch_geometric.data.Data:
osm_data = c2g.nx_to_pyg(G_osmnx)
osm_data
Data(x=[45081, 0], edge_index=[2, 114978], edge_attr=[114978, 0], pos=[45081, 2], graph_metadata=<city2graph.base.GraphMetadata object at 0x133cac050>)
Alternatively, it can be converted to GeoDataFrames compatible with the other city2graph functions:
osm_street_nodes, osm_street_edges =c2g.nx_to_gdf(G_osmnx)
osm_street_nodes.head()
| y | x | street_count | geometry | highway | ref | junction | |
|---|---|---|---|---|---|---|---|
| 324206 | 53.407123 | -2.884331 | 3 | POINT (-2.88433 53.40712) | NaN | NaN | NaN |
| 324209 | 53.405943 | -2.896571 | 3 | POINT (-2.89657 53.40594) | NaN | NaN | NaN |
| 324210 | 53.406113 | -2.896688 | 3 | POINT (-2.89669 53.40611) | motorway_junction | 4 | NaN |
| 324216 | 53.406488 | -2.899987 | 3 | POINT (-2.89999 53.40649) | NaN | NaN | NaN |
| 324217 | 53.407391 | -2.902198 | 3 | POINT (-2.9022 53.40739) | NaN | NaN | NaN |
osm_street_edges.head()
| osmid | highway | lanes | maxspeed | oneway | ref | reversed | length | bridge | geometry | name | junction | service | access | width | tunnel | est_width | |||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 324206 | 324209 | 0 | [28181793, 627409058, 546631471, 1155945617, 1... | motorway | 3 | [70 mph, 40 mph, 50 mph] | True | M62 | False | 823.913652 | yes | LINESTRING (-2.88433 53.40712, -2.88494 53.407... | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
| 324209 | 3467956864 | 0 | 15215646 | trunk | 3 | 40 mph | True | A5080 | False | 52.584979 | NaN | LINESTRING (-2.89657 53.40594, -2.89678 53.405... | Bowring Park Road | NaN | NaN | NaN | NaN | NaN | NaN |
| 324210 | 11385898804 | 0 | 15215652 | primary | NaN | 40 mph | True | A5080 | False | 32.304470 | NaN | LINESTRING (-2.89669 53.40611, -2.89629 53.406... | Bowring Park Road | NaN | NaN | NaN | NaN | NaN | NaN |
| 324216 | 21026685 | 0 | 4002276 | trunk | 3 | 40 mph | True | A5080 | False | 87.887476 | NaN | LINESTRING (-2.89999 53.40649, -2.90028 53.406... | Edge Lane Drive | NaN | NaN | NaN | NaN | NaN | NaN |
| 21026680 | 0 | 4002284 | trunk_link | NaN | 40 mph | True | NaN | False | 61.909425 | NaN | LINESTRING (-2.89999 53.40649, -2.90029 53.406... | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
To construct a morphological graph:
osm_buildings = osm_buildings.to_crs(epsg=27700)
osm_street_edges = osm_street_edges.to_crs(epsg=27700)
# Exclude rows with point geometries from osm_buildings
osm_buildings = osm_buildings[osm_buildings.geometry.type != "Point"]
# Define center point for the analysis area (Liverpool city centre)
center_point = gpd.GeoSeries([Point(-2.9879004, 53.4062724)], crs='EPSG:4326').to_crs(epsg=27700)
# Create the morphological graph
print("Creating morphological graph...")
morpho_nodes, morpho_edges = c2g.morphological_graph(
buildings_gdf=osm_buildings,
segments_gdf=osm_street_edges,
center_point=center_point,
distance=500, # Analysis radius in meters
clipping_buffer=300, # Buffer for edge effects
primary_barrier_col='barrier_geometry',
contiguity="queen", # Adjacency rule for tessellation
keep_buildings=True, # Preserve building geometries
keep_segments=True, # Preserve street segment geometries
)
Creating morphological graph...
/Users/yutasato/Projects/Liverpool/city2graph/.venv/lib/python3.13/site-packages/libpysal/weights/contiguity.py:347: UserWarning: The weights matrix is not fully connected:
There are 3 disconnected components.
There are 2 islands with ids: 50_('way', 659554384), 50_('way', 659554386).
W.__init__(self, neighbors, ids=ids, **kw)
print(f" - Node types: {list(morpho_nodes.keys())}")
print(f" - Edge types: {list(morpho_edges.keys())}")
print(f" - Place spaces: {len(morpho_nodes['place']):,}")
print(f" - Movement spaces: {len(morpho_nodes['movement']):,}")
for edge_type, edge_gdf in morpho_edges.items():
print(f" - {edge_type}: {len(edge_gdf):,} connections")
- Node types: ['place', 'movement']
- Edge types: [('place', 'touched_to', 'place'), ('movement', 'connected_to', 'movement'), ('place', 'faced_to', 'movement')]
- Place spaces: 1,489
- Movement spaces: 651
- ('place', 'touched_to', 'place'): 564 connections
- ('movement', 'connected_to', 'movement'): 1,299 connections
- ('place', 'faced_to', 'movement'): 3,276 connections
fig, ax = plt.subplots(figsize=(14, 12), facecolor='#f9f9f9')
morpho_nodes["place"]["building_geometry"].plot(ax=ax, color='#e0e0e0', edgecolor='#c0c0c0', linewidth=0.3, alpha=0.7)
morpho_nodes["movement"]["segment_geometry"].plot(ax=ax, color='#404040', linewidth=0.7, alpha=0.6)
c2g.plot_graph(
nodes=morpho_nodes,
edges=morpho_edges,
subplots=False,
ax=ax,
bgcolor="#f9f9f9",
labelcolor="#000000",
node_color={'place': 'red', 'movement': 'blue'},
edge_color={
('place', 'touched_to', 'place'): '#B22222',
('movement', 'connected_to', 'movement'): '#0000FF',
('place', 'faced_to', 'movement'): '#7B68EE',
},
markersize=10,
linewidth=0.2,
)
<Axes: >
osm_hetero_data = c2g.gdf_to_pyg(morpho_nodes, morpho_edges)
osm_hetero_data
HeteroData(
crs=EPSG:27700,
graph_metadata=<city2graph.base.GraphMetadata object at 0x133001640>,
place={
x=[1489, 0],
pos=[1489, 2],
},
movement={
x=[651, 0],
pos=[651, 2],
},
(place, touched_to, place)={
edge_index=[2, 564],
edge_attr=[564, 0],
},
(movement, connected_to, movement)={
edge_index=[2, 1299],
edge_attr=[1299, 0],
},
(place, faced_to, movement)={
edge_index=[2, 3276],
edge_attr=[3276, 0],
}
)