#!/usr/bin/env python3 """ Build Road Network Graph for Wuhan Respiratory Disease Risk Prediction Platform Extracts Wuhan OSM road network and builds graph structure """ import json import os import numpy as np import pandas as pd import geopandas as gpd from shapely.geometry import shape, MultiPolygon, Polygon from scipy.sparse import csr_matrix, lil_matrix import networkx as nx import pyrosm import warnings warnings.filterwarnings('ignore') # Paths PBF_PATH = '/home/akiba/CA/Datas/地图/hubei-260129.osm.pbf' WUHAN_GEOJSON = '/home/akiba/CA/Datas/武汉市.geojson' OUTPUT_DIR = '/home/akiba/CA/processed/graph' def load_wuhan_boundary(): """Load Wuhan boundary from geojson""" with open(WUHAN_GEOJSON, 'r', encoding='utf-8') as f: data = json.load(f) # Combine all district polygons into one geometries = [] for feat in data['features']: geom = shape(feat['geometry']) geometries.append(geom) # Create union of all geometries boundary = geometries[0] for g in geometries[1:]: boundary = boundary.union(g) return boundary, data['features'] def get_district_for_point(point, features): """Find which district a point belongs to""" for feat in features: geom = shape(feat['geometry']) if geom.contains(point): return feat['properties']['name'] return 'unknown' def build_road_graph(): """Build road network graph from OSM data""" print("Loading Wuhan boundary...") boundary, district_features = load_wuhan_boundary() print(f" Boundary type: {boundary.geom_type}") print("Reading OSM data...") # Initialize OSM reader with Wuhan boundary print(" Initializing OSM reader...") osm = pyrosm.OSM(PBF_PATH, bounding_box=boundary) # Get all drivable roads (more comprehensive than just primary/secondary) print("Extracting roads within Wuhan boundary...") # Filter to Wuhan boundary using bounding box first (faster) bounds = boundary.bounds print(f" Bounding box: {bounds}") # Read roads using pyrosm with custom filter # Get all highways first, then filter to boundary print(" Reading highways...") highways = osm.get_data_by_custom_criteria({ 'highway': ['motorway', 'trunk', 'primary', 'secondary', 'tertiary', 'unclassified', 'residential', 'living_street', 'pedestrian', 'track', 'service', 'road'] }) print(f" Total highway elements: {len(highways)}") if len(highways) == 0: print("ERROR: No highways found. Trying alternative approach...") return None # Convert to GeoDataFrame gdf = gpd.GeoDataFrame(highways, geometry='geometry', crs='EPSG:4326') print(f" GeoDataFrame size: {len(gdf)}") # Filter to Wuhan boundary print(" Clipping to Wuhan boundary...") gdf_clipped = gdf[gdf.geometry.is_valid].copy() gdf_clipped = gdf_clipped[gdf_clipped.intersects(boundary)] gdf_clipped = gdf_clipped.geometry.apply(lambda g: g.intersection(boundary) if g.is_valid else None) gdf_clipped = gdf_clipped.dropna() # Explode MultiLineStrings to LineStrings def explode_geom(g): if g.geom_type == 'MultiLineString': return list(g.geoms) elif g.geom_type == 'LineString': return [g] elif g.geom_type == 'MultiPolygon': # Get all polygon exteriors as LineStrings result = [] for poly in g.geoms: result.append(poly.exterior) return result elif g.geom_type == 'Polygon': # Intersection of a LineString with boundary can return Polygon return [g.exterior] elif g.geom_type == 'GeometryCollection': result = [] for geom in g.geoms: result.extend(explode_geom(geom)) return result return [] all_geoms = [] for g in gdf_clipped.geometry: all_geoms.extend(explode_geom(g)) print(f" Total line segments after clipping: {len(all_geoms)}") if len(all_geoms) == 0: print("ERROR: No geometries after clipping") return None # Build graph print("Building graph structure...") G = nx.MultiDiGraph() node_id_counter = 0 node_info = {} # osmid -> (lat, lon, district, road_type) # First pass: collect all unique points all_points = set() point_to_node = {} for i, geom in enumerate(all_geoms): coords = list(geom.coords) for coord in coords: all_points.add(coord) print(f" Total unique points: {len(all_points)}") # Map points to node IDs for pt in all_points: point_to_node[pt] = node_id_counter node_id_counter += 1 # Add nodes to graph for pt, nid in point_to_node.items(): G.add_node(nid, osmid=nid, x=pt[0], y=pt[1]) # Second pass: create edges from line segments edge_count = 0 edges_data = [] for geom in all_geoms: coords = list(geom.coords) for i in range(len(coords) - 1): u = point_to_node[coords[i]] v = point_to_node[coords[i+1]] # Calculate edge weight (1/length_km) dx = coords[i+1][0] - coords[i][0] dy = coords[i+1][1] - coords[i][1] length_deg = np.sqrt(dx**2 + dy**2) # Approximate conversion at Wuhan latitude (30N) length_km = length_deg * 111.32 * np.cos(np.radians(30)) length_km = max(length_km, 0.0001) # avoid division by zero weight = 1.0 / length_km G.add_edge(u, v, weight=weight, length=length_km) edges_data.append((u, v, length_km, weight)) edge_count += 1 print(f" Graph nodes: {G.number_of_nodes()}") print(f" Graph edges: {G.number_of_edges()}") # Check node count and apply fallback if needed if G.number_of_nodes() > 70000: print("\nNode count exceeds 70k, applying highway filter...") # Filter to major roads only major_roads = osm.get_data_by_custom_criteria({ 'highway': ['motorway', 'trunk', 'primary', 'secondary', 'tertiary'] }) gdf_major = gpd.GeoDataFrame(major_roads, geometry='geometry', crs='EPSG:4326') gdf_major = gdf_major[gdf_major.geometry.is_valid].copy() gdf_major = gdf_major[gdf_major.intersects(boundary)] # Rebuild graph G = nx.MultiDiGraph() node_id_counter = 0 point_to_node = {} all_geoms = [] for g in gdf_major.geometry: all_geoms.extend(explode_geom(g)) all_points = set() for geom in all_geoms: coords = list(geom.coords) for coord in coords: all_points.add(coord) for pt in all_points: point_to_node[pt] = node_id_counter node_id_counter += 1 for pt, nid in point_to_node.items(): G.add_node(nid, osmid=nid, x=pt[0], y=pt[1]) for geom in all_geoms: coords = list(geom.coords) for i in range(len(coords) - 1): u = point_to_node[coords[i]] v = point_to_node[coords[i+1]] dx = coords[i+1][0] - coords[i][0] dy = coords[i+1][1] - coords[i][1] length_deg = np.sqrt(dx**2 + dy**2) length_km = length_deg * 111.32 * np.cos(np.radians(30)) length_km = max(length_km, 0.0001) weight = 1.0 / length_km G.add_edge(u, v, weight=weight, length=length_km) print(f" Filtered graph nodes: {G.number_of_nodes()}") print(f" Filtered graph edges: {G.number_of_edges()}") node_count = G.number_of_nodes() if node_count < 15000 or node_count > 70000: print(f"WARNING: Node count {node_count} outside target range 15k-70k") # Check connectivity print("\nChecking graph connectivity...") if G.number_of_nodes() > 0: # Get largest weakly connected component if G.is_directed(): connected = list(nx.weakly_connected_components(G)) else: connected = list(nx.connected_components(G)) largest_cc = max(connected, key=len) print(f" Total components: {len(connected)}") print(f" Largest component size: {len(largest_cc)}") print(f" Largest component ratio: {len(largest_cc)/G.number_of_nodes():.2%}") # Keep only largest component nodes_to_remove = set(G.nodes()) - set(largest_cc) G.remove_nodes_from(nodes_to_remove) print(f" After pruning to largest CC: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges") # Relabel nodes to consecutive integers 0..n-1 for adjacency matrix old_nodes = list(G.nodes()) new_nodes = range(len(old_nodes)) mapping = dict(zip(old_nodes, new_nodes)) G = nx.relabel_nodes(G, mapping, copy=False) print(f" Relabeled nodes to consecutive IDs 0..{G.number_of_nodes()-1}") # Build output files print("\nGenerating output files...") # 1. Node metadata node_data = [] for nid in G.nodes(): props = G.nodes[nid] # Approximate lat/lon lat = props.get('y', 0) lon = props.get('x', 0) node_data.append({ 'osmid': nid, 'lat': lat, 'lon': lon, 'district': 'unknown', # Would need reverse geocoding 'road_type': 'unknown' }) node_df = pd.DataFrame(node_data) node_df.to_parquet(f'{OUTPUT_DIR}/node_metadata.parquet', index=False) print(f" Saved node_metadata.parquet: {len(node_df)} nodes") # 2. Edge list edge_data = [] for u, v, data in G.edges(data=True): edge_data.append({ 'source': u, 'target': v, 'weight': data.get('weight', 1.0), 'length_km': data.get('length', 0) }) edge_df = pd.DataFrame(edge_data) edge_df.to_csv(f'{OUTPUT_DIR}/edge_list.csv', index=False) print(f" Saved edge_list.csv: {len(edge_df)} edges") # 3. Adjacency matrix (sparse CSR) print(" Building adjacency matrix...") n = G.number_of_nodes() adj = lil_matrix((n, n), dtype=np.float32) for u, v, data in G.edges(data=True): adj[u, v] = data.get('weight', 1.0) # Make it symmetric for undirected use adj[v, u] = data.get('weight', 1.0) adj_csr = adj.tocsr() np.savez(f'{OUTPUT_DIR}/adjacency_matrix.npz', data=adj_csr.data, indices=adj_csr.indices, indptr=adj_csr.indptr, shape=adj_csr.shape) print(f" Saved adjacency_matrix.npz: {adj_csr.shape}") # Verify outputs print("\n=== VERIFICATION ===") print(f"Node count: {G.number_of_nodes()}") print(f"Edge count: {G.number_of_edges()}") print(f"Target range: 15,000 - 70,000") # Check components if G.number_of_nodes() > 0: if G.is_directed(): components = list(nx.weakly_connected_components(G)) else: components = list(nx.connected_components(G)) print(f"Connected components: {len(components)}") # Verify files exist for fname in ['adjacency_matrix.npz', 'edge_list.csv', 'node_metadata.parquet']: fpath = f'{OUTPUT_DIR}/{fname}' if os.path.exists(fpath): size = os.path.getsize(fpath) print(f" {fname}: {size/1024:.1f} KB") else: print(f" {fname}: MISSING") print("\nDone!") return G if __name__ == '__main__': G = build_road_graph()