이것저것 기록

[python/GIS] QGIS 없이 파이썬만 사용해 최단거리 구하기 (OSMnx, geopandas) 본문

코린이/실무를 위한 코딩 기록

[python/GIS] QGIS 없이 파이썬만 사용해 최단거리 구하기 (OSMnx, geopandas)

anweh 2023. 1. 16. 14:35

오늘은 단순 직선 거리가 아닌, 도로 네트워크 상 두 지점 간 최단거리를 계산하는 코드를 포스팅 하려 한다. 

README를 포함한 샘플 데이터는 깃헙을 참조하길! 

https://github.com/henewsuh/osm_shortestPath

 

GitHub - henewsuh/osm_shortestPath: Calculate the distance to the nearest subway station using OSMnx library

Calculate the distance to the nearest subway station using OSMnx library - GitHub - henewsuh/osm_shortestPath: Calculate the distance to the nearest subway station using OSMnx library

github.com

 

 

0. 코드 설명에 앞서...

- 이 코드는 QGIS 없이 파이썬 만으로 최단거리를 계산하기 위한 코드이다. 

- OSMnx라는 라이브러리를 사용했기 때문에, 도로 네트워크(출발점)와 지하철역(도착점) 관련 데이터를 Open Street Map 상에서 모두 해결하면 코드가 한결 간편해진다. 즉, 샘플 데이터가 아니라 그냥 Open Street Map 데이터를 가져다 쓴다면 코드가 간단해진다는 거다. 정확히 어디가 간단해지는 지는 이후 코드 설명에서 덧붙이기로 ㅇㅇ 

- 이 코드는 사용자가 특정 출발점과 도착점 관련 데이터를 바탕으로 최단거리를 계산하려 할때 필요한 코드일 것이다. 

 

 

1. 실행 환경 정보 (주요 라이브러리만)

python == 3.8.15
shapely == 2.0.0
pyproj == 3.4.1
networkx == 3.0
numpy == 1.24.1
pandas == 1.5.2
GDAL == 3.6.2
geopandas == 0.12.2
Fiona == 1.8.22
osmnx == 1.3.0

geopandas 설치시엔 의존 패키지 버전 충돌이 일어나지 않게 꼭 '순서대로' 다운 받길 바란다...

gpd는 설치 과정이.. 매우 귀찮고 까다로움.... 그치만 필수 라이브러리라 다운 받는 수밖에..ㅎ

 

 

2. 코드 설명 

2-1. Import Libraries and Load Data

import os 
import geopandas as gpd 
import osmnx as ox 
import networkx as nx
from shapely.geometry import Point
import numpy as np
from tqdm import tqdm 
import matplotlib.pyplot as plt


''' Load Data '''
root = os.getcwd()
data_path = os.path.join(root, 'data')
subway = gpd.read_file(os.path.join(data_path,'Gangnam-gu_subway.shp'))
segment = gpd.read_file(os.path.join(data_path,'Gangnam-gu_streets.shp'))
centroid = segment['geometry'].centroid
print(subway.crs)
print(segment.crs)
print(centroid.crs)

'Load Data'의 내용에서 알 수 있듯이, 이 코드의 최단경로 출발점은 "segment" -- 강남구 도로 세그먼트의 중심점이다. 도착점은 "subway" -- 강남구에 속하는 지하철역이다. 

출발점과 도착점에 들어가는 데이터를 변경하면 자유자재로 코드 활용이 가능하다. 

print 문에서는 각각의 데이터들의 좌표 설정을 확인한다. 

유클리디안 방법으로 거리를 계산하기 위해 EPSG:5179로 좌표계를 맞춰주었다.

위경도 좌표계를 사용한다면 유클리디안 방법이 아닌 haversine 방법을 사용해 거리 계산을 진행한다.

 

 

2-2. Extract Coordinates

''' Extract Coordinates '''
crds = [[list(centroid.geometry[i].coords)[0][0], list(centroid.geometry[i].coords)[0][1]] for i in range(len(centroid))]
crds_arr = np.array(crds) 
sub_crds = [[list(subway.geometry[i].coords)[0][0], list(subway.geometry[i].coords)[0][1]] for i in range(len(subway))]
sub_crds_arr = np.array(sub_crds)

Geometry 객체에서 X, Y 좌표값에 대한 정보만 추출한다. 

 

 

2-3. Load OSM streets as a Graph using OSMnx

''' Load OSM streets as a Graph using OSMnx '''
G = ox.graph_from_place('강남구, 서울특별시, 대한민국', network_type='all_private') # 오래 걸림
G_utm = ox.project_graph(G, to_crs="epsg:5179")

여기부터 메인 코드의 시작이다. 

OSMnx를 사용해 추출하고자 하는 지역명을 string으로 입력하여, 해당 지역의 도로 네트워크를 G (Graph)형태로 반환한다. 

첫 줄에서는 ox.graph_from_place() 함수를 사용하였지만 지역명 이외에도 

  • graph_from_address()
  • graph_from_bbox()
  • graph_from_point()
  • graph_from_polygon()
  • graph_from_xml()

등 다양한 그래프 추출 함수들이 존재한다. (https://osmnx.readthedocs.io/en/stable/osmnx.html#module-osmnx.graph)

더불어 불러오고자 하는 도로 네트워크의 종류도 파라미터로 지정할 수 있다.

  • network_type : string {"all_private", "all", "bike", "drive", "drive_service", "walk"}

"강남구, 서울특별시, 대한민국"보다 넓은 범위인 "서울특별시, 대한민국"을 파라미터로 설정하였을 시, 서울특별시의 모든 지역의 도로 네트워크가 하나의 거대그래프로 반환된다. 반환까지의 시간이 매우 오래 걸린다, 주의하길 바람... 

 

 

2-4. Get the Nearest Nodes and Plot

''' Get Nearest Nodes using OSMnx '''
nearest, dist_st = ox.distance.nearest_nodes(G_utm, X=crds_arr[:,0], Y=crds_arr[:,1], return_dist=True)
nearest_sub, dist_sub = ox.distance.nearest_nodes(G_utm, X=sub_crds_arr[:,0], Y=sub_crds_arr[:,1], return_dist=True)


''' Plot OSMnx graph with nearest subway nodes and nearest stree nodes '''
G_utm_nodes = list(G_utm.nodes)
target_idx = [G_utm_nodes.index(nearest[i]) for i in range(len(nearest))]
subway_idx = [G_utm_nodes.index(nearest_sub[i]) for i in range(len(nearest_sub))]

nc = ['black']* len(list(G_utm.nodes))
for i in range(len(nc)): 
    if i in target_idx: 
        nc[i] = 'blue'
    if i in subway_idx: 
        nc[i] = 'red'
ns = [0.0005]* len(list(G_utm.nodes))
for i in range(len(ns)): 
    if i in target_idx: 
        ns[i] = 0.03
    if i in subway_idx: 
        ns[i] = 10

fig, ax = ox.plot_graph(G_utm, bgcolor='w', node_color=nc, node_edgecolor=None, node_size=ns,
                            node_zorder=3, edge_color='black', edge_linewidth=0.2, dpi=300)

바로 이 단락이 앞서 말했던 "Open Street Map 상에서 모두 해결하면 코드가 한결 간편해진다"에 해당하는 코드이다. 

출발점과 도착점 모두 OSM에서 제공하는 데이터를 사용한다면 "내가 가지고 있는 출발/도착점 데이터"를 "OSM 그래프의 좌표"와 매칭 시킬 작업이 필요 없다. 

무슨 말이냐 하면, 

  • OSMnx의 최단거리 계산은 nx.shortest_path_length()라는 함수를 사용하는데
  • 이때 해당 함수의 파라미터로 그래프(G), 출발 노드, 도착 노드를 입력한다.
  • 여기서 문제는 출발/도착 노드의 정보가 좌표가 아닌, 인덱스 "번호"로 들어가야 한다는 것이다.
  • 만약 출발/도착 노드를 OSM 상에서 구했다면 상관 없지만, 특정 데이터 (도로명주소 데이터, 따릉이 대여소)등을 출발/도착 지점으로 사용하고 싶다면 -- 그 특정 데이터에 해당하는 (혹은 가장 가까운) 그래프(G) 상 노드 인덱스를 찾아야하는 문제가 발생한다. 

골치 아프다... 정말 골치 아프다.... 

그래서 최단거리를 계산할 일이 있으면 최대한 OSM 상에서 모든 데이터를 해결하는 것이 좋다고 말하고 싶다...ㅋ.. 은근 데이터 퀄리티도 괜찮음. 

 

자, 다시 본론으로 돌아와서... 

첫 번째 두 줄이 바로 "특정 데이터에 해당하는 (혹은 가장 가까운) 그래프(G) 상 노드 인덱스를 찾는" 코드이다. 

그 밑부턴 아래 plot을 위한 빌드업 코드임..ㅇㅇ

강남구 도로의 중심점(blue), 지하철역(red), 도로네트워크(black)

 

 

2-5. Calculate the Shortest Path

''' Calculate the Shortest Path '''
shortest_dist2sub = []
for i in tqdm(range(len(nearest))): 
    centroid_point = (G_utm.nodes[nearest[i]]['x'], G_utm.nodes[nearest[i]]['y'])
    buffer = Point(centroid_point).buffer(1000) # 1000m 이내의 지하철역까지만 고려함 
    temp = []
    for j in range(len(nearest_sub)): 
        subway_point_xy = (G_utm.nodes[nearest_sub[j]]['x'], G_utm.nodes[nearest_sub[j]]['y'])   
        if buffer.contains(Point(subway_point_xy)):
            try:
                dist = nx.shortest_path_length(G_utm, nearest[i], nearest_sub[j],weight='length')
                temp.append(dist)
            except: 
                pass
    if len(temp) > 0 : 
        shortest_dist2sub.append(min(temp))
    else: 
        shortest_dist2sub.append(None)


''' Export the results '''
segment['dist2sub'] = shortest_dist2sub # add the distance to the nearest sub as a new column 
segment.to_file(os.path.join(data_path, 'result.shp'), encoding='cp949')

이번 코드의 꽃이라 말할 수 있는 부분이다 (이중 for-loop인 것은 살짝 부끄럽 ^^;;)

흠흠 어쨌든 shortest_dist2sub에 각 출발점의 가장 가까운 지하철역까지의 거리가 m단위로 담긴다. 

  1. 출발점인 도로 세그먼트의 centroid 만큼 1차 for-loop을 돈다. 
  2. i번째 centroid가 원점이면서 반경이 1000m인 원 폴리곤("buffer")를 생성한다. 
  3. 도착점인 지하철역의 좌표만큼 2차 for-loop을 도는데, 이때 j번째 좌표가 "buffer"의 범위에 포함되면 최단거리를 계산한다.
  4. 계산된 최단거리는 temp에 쌓는다. 
  5. 2차 for-loop에서 빠져나온 후 temp의 min값만 최종 최단거리로 남겨둔다. 

result 시각화 결과:

결과 이미지

  • point: 지하철역
  • 녹색이 연할수록 지하철역과 가깝다는 뜻
  • 빨간색: 인근 지하철역까지의 거리가 1200m 이상 3600m 이하 
  • 지하철역까지 거리가 너무 멀어서 'None'으로 표기한 도로 

도로명주소 데이터를 OSM 그래프의 도로로 매핑하는 부분에서 에러가 나는 도로가 일부 있다. 

(육안으로 봐도 1000m 안에 인접 지하철역이 있음에도 계산된 거리가 터무니 없이 멀다거나...)

이러한 부분은 추후 후처리를 통해 버리고 가야할 부분인듯. 

어쨌든 오늘의 포스팅 끝 :) 일년 만의 포스팅 ^___^..

Comments