dersblog

OSM Haritaları, PBF Dosyaları, En Kısa Yol, Djikstra

Yol ağını içeren OSM haritasını kendimiz işleyip erişimi hızlı istediğimiz formata çevirmek istersek bu mümkündür, OSM dosyaları [1] sitesinde bedava paylaşılıyor, ve çevrimin ilk aşaması OSM -> düz text CSV bazlı dönüşüm Rust [5] ile yazılmış osm4routing kodunda var. Bu yazıda amaç iki nokta arasında kısa yol algoritması bulan algoritma yazmak olacak.

osm4routing kurmak için

cargo install osm4routing

Örnek olarak Şeysel (Seychelles) adalarına bakalım, ufak bir dosya olduğu için örnekleri göstermek işletmek hızlı olur. Haritası Afrika dizini altında, oradaki osm.pbf dosyası indirilir. $HOME/Downloads altında olduğunu farzedelim,

osm4routing $HOME/Downloads/seychelles-latest.osm.pbf

Program oldukça hızlı işler, bitince iki tane dosya, edges.csv ve nodes.csv üretilmiş olmalı. İçeriklerine bakalım (ilk birkaç satır),

! head -10 nodes.csv
id,lon,lat
5766693114,55.2028362,-3.7270749
2737905006,55.694301599999996,-4.3259219
2710742974,55.4645328,-4.6004604
6808483052,55.462411599999996,-4.632185799999999
8979383052,55.518222699999995,-4.716342399999999
9144642426,55.4628521,-4.5883534
6407473046,55.4584707,-4.6071178999999995
8979071049,55.4408661,-4.624990299999999
3789265673,55.401677899999996,-4.6558733
! head -3 edges.csv
id,osm_id,source,target,length,foot,car_forward,car_backward,bike_forward,bike_backward,train,wkt
26771422-0,26771422,293645412,1159221829,113.98597980501721,Allowed,Secondary,Secondary,Allowed,Allowed,Forbidden,"LINESTRING(55.7601390 -4.3462977, 55.7602171 -4.3463866, 55.7602852 -4.3464620, 55.7603906 -4.3466138, 55.7604949 -4.3467581, 55.7605343 -4.3468123, 55.7605801 -4.3468621, 55.7606248 -4.3468929, 55.7606791 -4.3469152, 55.7607355 -4.3469237, 55.7608292 -4.3469269, 55.7608735 -4.3469255)"
26771422-1,26771422,1159221829,2330448860,71.52456370873085,Allowed,Secondary,Secondary,Allowed,Allowed,Forbidden,"LINESTRING(55.7608735 -4.3469255, 55.7613600 -4.3469271, 55.7614284 -4.3469290, 55.7614753 -4.3469333, 55.7615168 -4.3469417)"

Çizit (graph) teorisi açısından bakarsak üstte bir ağ / çizit yapısı var, ilk dosyadakiler düğümler (nodes) ikincidekiler ise kenarlar (edges). Düğümler yeryüzünde bazı noktalar, bir durak olabilir, yol ağzı olabilir, ya da yol üzerindeki bir nokta. Her düğümün bir id kimliği var, ve bu id ile o noktanın kordinat değerlerine enlem boylam üzerinden erişebiliyoruz. Kenarlar bir düğümü bir diğerine bağlayan yollar gibi görülebilir, bağlantı parçaları. Her kenarın da bir kimliği var, ve ayrıca çıkış noktası source bitiş noktası target bilgisini taşıyor. Bu iki kolon tabii ki düğüm verisindeki id değerlerine tekabül ediyor, kenar bir düğümden çıkıp diğerinde bitiyor.

Kenarların, yani yolların taşıdığı bazı ek önemli bilgiler var; mesela bir yolun yürümeye elverişli olup olmadığı (foot kolonunda Allowed değeri var ise), aynı şekilde araba, bisiklet kullanımına uygun olup olmadığı ve nihai olarak ne kadar uzun olduğu (length) yol bilgisi içinde mevcut.

Düğüm Veri Yapısı, Yakın Nokta Bulmak

Kısa yol algoritması işletmek için bize neler lazım? Yol tarifi isterken bir başlangıç ve bitiş noktası enlem/boylam olarak verilir, bu iki noktanın OSM düğüm noktalarına eşlenmesi gerekiyor, aynen [3] yazısında olduğu gibi önce verilen kordinatlara en yakın OSM noktası bulunmalı, ve oradan sonra düğüm, kenar, sonraki düğüm vs diye yol arama algoritması işleyebilir.

Fakat bir liste içinden bir noktaya en yakın veri noktasını bulmak performans açısından o kadar kolay bir iş değil; örnek olarak burada ufak veri kullandık ama mesela TR boyutunda bir haritada milyonlarca nokta ve onların arasında bağlantılar olacaktır. Milyonlarca satır içinden en yakın olanını bulmak eğer tüm verilere teker teker bakılıyorsa uzun sürebilir. Bize bir tür indeksleme (indexing) mekanizması gerekiyor.

İlk akla gelebilecek çözümler QuadTree, KDTree gibi seçenekler, fakat bu çözümlerin çoğu bellek bazlı işler; etrafta bulunabilecek mevcut kodlar milyonlarca veri noktasını alıp bir indislenmiş ağaç yapısı oluşturabilir ama bunu veri yapısını hafıza tutarak yapar. İdeal olarak nokta bulmak, kısa yol hesaplama algoritmasının ufak bilgisayar üzerinde işleyebilmesi tercihimiz (bir ağaç yapısını hafızaya diskten geri aldığımızda gigabayt seviyesinde olmamalı). Eğer ağır işlem bedeli ödenecekse onun baştan, veri hazırlığı evresinde ödenmesi daha iyi olacaktır.

Şöyle bir çözüm olabilir, harita üzerinde bir ızgara (grid) oluştururum, 4 x 4, ya da 3 x 4 boyutunda olabilir, bu bana 12 ızgara noktası verir, sonra veriyi baştan sonra işlerken elimdeki her düğüm için onun en yakın olduğu iki ızgara noktasını bulurum ve yeni bir tabanda kaydederim. Bu yeni dosyayı bir SQL tabanına yazarım, her satırda yakın ızgara noktaları mesela kolonlar c1 ve c2 olabilir ve yeni tabloyu bu kolonlar bazlı indekslerim, böylece c1 ve c2 bazlı filtreleme işlemi hızlanır.

Referans ızgara noktalarını (3 x 4 için 12 tane zaten) bir pickle içinde kaydedebilirim, böylece sonradan isteyen yükleyebilir, ve artık herhangi bir nokta için aynı yakınlık hesabı işletilir, mesela c1=3, c2=5 bulundu diyelim ve SQL tabanından ya 3 ya da 5 değerine sahip olan düğümleri SELECT ile alırım, ve bu noktalar üzerinde detaylı yakınlık hesabı işletirim. Böylece gerçek mesafe hesabı yapacağım veri miktarını azaltmış oldum. Bu mantıklı olmalı, haritayı bölgelere ayırıyorum bir bakıma, eğer elimde Karadeniz bölgesinden bir nokta varsa Akdeniz bölgesindeki noktalara bakmaya ne gerek var?

Burada seçilen teknolojilerin özelliklerine, kuvvetlerine dikkat; ızgara noktası bazlı filtreleme için SQL kullandık çünkü tam sayı bazlı filtreleme işlerini hem disk bazlı (herşeyi hafızaya almadan) ve çok hızlı yapar. Izgara ataması yaparken nodes.csv satır satır işlenecek, ve o sırada satır satır SQL tabanına yazım yapılacak, bu da hızlı, mesafe hesabı yapılıyor ama sadece 12 ızgara noktası için yapıldığı için çok performans kaybı yok.

import csv, numpy as np, re, os, shutil, pickle, sqlite3
from pygeodesy.sphericalNvector import LatLon
from scipy.spatial.distance import cdist
import pandas as pd

dbfile = "nodes.db"

def grid_assign_centers(corner1,corner2):

    p1 = LatLon(corner1[0],corner1[1])
    p2 = LatLon(corner2[0],corner2[1])

    lowlat = np.min([p1.lat,p2.lat])
    lowlon = np.min([p1.lon,p2.lon])
    hilat = np.max([p1.lat,p2.lat])
    hilon = np.max([p1.lon,p2.lon])

    x = np.linspace(lowlon,hilon,3)
    y = np.linspace(lowlat,hilat,4)

    xx,yy = np.meshgrid(x,y)
    mids = []
    for x,y in zip(xx.flatten(), yy.flatten()):
        mids.append([x,y])       
    mids = np.array(mids)

    pickle.dump(mids, open('centers.pkl', 'wb'))        

    if os.path.exists(dbfile): os.remove(dbfile)    
    db = sqlite3.connect(dbfile)
    cursor = db.cursor()
    cursor.execute('''CREATE TABLE osm_nodes(id INTEGER PRIMARY KEY, 
                      lat NUMERIC, lon NUMERIC, c1 INTEGER, c2 INTEGER)
                      ''')
    db.commit()

    cursor = db.cursor()
    with open('nodes.csv') as csvfile:
        rd = csv.reader(csvfile,delimiter=',')
        headers = {k: v for v, k in enumerate(next(rd))}
        for i,row in enumerate(rd):        
            id,lat,lon = row[headers['id']],row[headers['lat']],row[headers['lon']]
            ds = cdist(mids,np.array([[lon,lat]]))
            res = list(np.argsort(ds,axis=0).T[0][:2])
            cursor.execute('''INSERT INTO osm_nodes(id, lat, lon, c1, c2)
                      VALUES(?,?,?,?,?)''', (id,lat[:8],lon[:8],int(res[0]),int(res[1])))            
            if i % 1000 == 0:
                print ('satir',i)
                db.commit()

    cursor = db.cursor()
    cmd = "CREATE INDEX index1 ON osm_nodes(c1)"
    cursor.execute(cmd)
    cmd = "CREATE INDEX index2 ON osm_nodes(c2)"
    cursor.execute(cmd)
    db.commit()

# iki tane kose noktasini haritadan elle sectik
grid_assign_centers((-4.807419070202981, 55.364345234773644),
                    (-4.549969190633921, 55.566362543604434))
satir 0
satir 1000
satir 2000
satir 3000
satir 4000
satir 5000
satir 6000

Tablo osm_nodes yaratıldı. Dikkat, c1 ve c2 üzerindeki indeksler tüm satırlar eklendikten sonra yaratıldı. Eğer boş tablo üzerinde bu indeksleri yaratmış olsak INSERT işlemleri yavaşlardı. Toptan INSERT yaparken indekslere ihtiyaç yok çünkü bir toptan veri hareketi işlemi bu, indeksler sonradan lazım olacak. Bu tipik bir mühendislik kar/zarar denge hesabı (trade-off).

Seçilen köşe ve hesaplanan ızgara noktaları altta grafikleniyor,

Şimdi bu ızgara noktalarını kullanarak bize "kordinata en yakın olan OSM id'sini bul" mantığını kodlayabiliriz.

def find_closest_node(lat,lon):
    mids = pickle.load(open("centers.pkl","rb"))

    conn = sqlite3.connect(dbfile)

    frvec = np.array([lon,lat]).reshape(1,2)
    ds = cdist(mids,frvec)
    fr_closest_mid = list(np.argsort(ds,axis=0).T[0][:2])
    frres = []
    sql = "select id,lat,lon from osm_nodes where c1==? or c1==? or c2==? or c2==?"
    c = conn.cursor()
    rows = c.execute(sql,(int(fr_closest_mid[0]),
                          int(fr_closest_mid[1]),
                          int(fr_closest_mid[0]),
                          int(fr_closest_mid[1])))
    for row in rows: frres.append(row)

    df = pd.DataFrame(frres); df.columns = ['id','lat','lon']

    frres = cdist(df[['lon','lat']], frvec)
    res = df.iloc[np.argmin(frres)][['id','lat','lon']]
    return list(res)

Altta iki nokta seçtik, bunları birazdan yol hesabı için başlangıç bitiş noktaları olarak kullanacağız,

fr=(-4.699287820423064, 55.49185927728346)
to=(-4.6364140603708925, 55.406975784999176)

find_closest_node(fr[0],fr[1])
Out[1]: [5241652028.0, -4.70279, 55.48997]
find_closest_node(to[0],to[1])
Out[1]: [8059195265.0, -4.63801, 55.40781]

Bu noktalar hakikaten de benim seçtiğim yerlere yakın. Demek ki verilen OSM kimliğini (listedeki üç sayıdan ilki) kullanabilirim.

Bağlantılar

İkinci teknoloji seçimine gelelim, bu bir önceki konu kadar önemli, yolları temsil eden çiziti, düğümler ve aralarındaki bağlantıları nasıl temsil edeceğiz? Bu seçimi yaparken aklımda bazı tercihler ve bilgiler var. Mesela [7] yazısında anlatılan kısa yol algoritmasının Python sözlüğü bazlı çalıştığını biliyorum, çiziti bir "sözlük içinde sözlük" yapısında olmasını bekliyor, yani çizit G ise mesela G['a'] ile G sözlüğünden ikinci bir sözlük elde ediyoruz, bu sözlükte hedef düğümü geçiyoruz, bu bize yolun ağırlığını / uzaklığını veriyor, G['a']['b'] ile a düğümünün b düğümüne uzaklığını elde ediyorum.

İkinci tercih daha önceki durumda olduğu gibi herşeyi hafızaya almaktan kaçınmak. Mümkün olduğu kadar herşeyi disk bazlı yapmak. Bu bizi nihai teknoloji tercihine götürüyor - disk bazlı bir sözlük! Daha önceki bir yazıda [6] bunu görmüştük, diskdict hızlı çalışan bir paket. O zaman kenar verilerini bir diskdict sözlüğüne ekleyerek ikinci veri yapısını elde edebilirim.

Algoritmayı yazalım, edges.csv dosyasını satır satır gezerken her çıkış düğümü source ile bitiş noktası target arasında length uzaklığını sözlük içindeki sözlüğe ekliyoruz. Not: Çizitimizi yürüyüş için hazırlayacağız, yani car, bike gibi seçenekleri olan ama yürüyüşe izin vermeyen yollar alınmayacak.

from diskdict import DiskDict
import os, csv, shutil

dictdir = "walkdict"

def diskdict():

    if os.path.exists(dictdir): shutil.rmtree(dictdir)
    print ('bos hucreleri yarat')
    dd = DiskDict(dictdir)
    with open('edges.csv') as csvfile:
        rd = csv.reader(csvfile,delimiter=',')
        headers = {k: v for v, k in enumerate(next(rd))}
        for i,row in enumerate(rd):        
            if i % 1000 == 0: print ('satir', i)
            if row[headers['foot']] == 'Allowed':
                dd[row[headers['source']]] = {}
                dd[row[headers['target']]] = {}

    dd.close()

    print ('baglantilari ekle')
    dd = DiskDict(dictdir)
    with open('edges.csv') as csvfile:
        rd = csv.reader(csvfile,delimiter=',')
        headers = {k: v for v, k in enumerate(next(rd))}
        for i,row in enumerate(rd):        
            if i % 1000 == 0: print ('satir',i)
            if row[headers['foot']] == 'Allowed':

                tmp = dd[row[headers['source']]]
                tmp[row[headers['target']]] = row[headers['length']]
                dd[row[headers['source']]] = tmp

                tmp = dd[row[headers['target']]]
                tmp[row[headers['source']]] = row[headers['length']]
                dd[row[headers['target']]] = tmp

    dd.close()

diskdict()
bos hucreleri yarat
satir 0
satir 1000
satir 2000
satir 3000
satir 4000
satir 5000
satir 6000
satir 7000
baglantilari ekle
satir 0
satir 1000
satir 2000
satir 3000
satir 4000
satir 5000
satir 6000
satir 7000

Oldu mu acaba? Biraz önce yukarıda bulduğumuz iki OSM id için kontrol edebilirim,

dd = DiskDict(dictdir)
print (dd['5241652028'])
print (dd['5241649212'])
dd.close()
{'5241649212': '457.3817484566977'}
{'4777625846': '53.01967846005421', '4777625831': '290.6337430695447', '5241652028': '457.3817484566977'}

İşliyor gibi gözüküyor. Şimdi kısa yol algoritmasina gelelim, bu algoritmayi [7]'de işledik ve bu yazıdaki formu direk [8] bağlantısından aldık.

from priodict import priorityDictionary

def Dijkstra(G, start, end=None):
    D = {}  
    P = {}  
    Q = priorityDictionary()  
    Q[start] = 0

    for v in Q:
        D[v] = Q[v]
        if v == end:
            break

        for w in G[v]:
            vwLength = D[v] + float(G[v][w])
            if w in D:
                if vwLength < D[w]:
                    raise ValueError("Dijkstra: found better path to already-final vertex")
            elif w not in Q or vwLength < Q[w]:
                Q[w] = vwLength
                P[w] = v

    return (D, P)

def shortestPath(G, start, end):

    D, P = Dijkstra(G, start, end)
    Path = []
    while 1:
        Path.append(end)
        if end == start:
            break
        end = P[end]
    Path.reverse()
    return Path

Şimdi başlangıç ve bitiş noktası olarak önceden bulduğumuz değerleri geçelim,

dd = DiskDict(dictdir)
path = shortestPath(dd,'5241652028','8059195265')
print (path)
dd.close()
['5241652028', '5241649212', '4777625846', '405266842', '3802966016', '405266742', '405266728', '6431706479', '305690088', '305690096', '305690111', '3802965784', '3802965685', '305690121', '2354805430', '1614295880', '1864118317', '305691481', '3802965494', '305691485', '3802965479', '305691491', '1417297967', '305691500', '305691521', '2802777348', '305691544', '7382949169', '7382949164', '305691549', '6437197545', '305691560', '305691563', '2802805342', '305691567', '439186462', '305691570', '305691582', '305691586', '6437232101', '305691589', '6437232098', '2573268555', '1864118244', '1864118235', '8918610006', '9225502574', '305691605', '2802788703', '6437269807', '6437269803', '305691623', '305691629', '305691642', '306655753', '306655762', '3049391257', '306655819', '9926906798', '2379086156', '6437318872', '395271770', '3190260407', '398384289', '6437348771', '2008248197', '5517625607', '9233918566', '398384449', '1198102870', '1198102890', '1198114558', '1198114523', '8059195265']

Bir yol bulundu gibi duruyor. Yol tabii ki osm id bazında listelendi, bu düğüm noktalarının kordinat değerlerini bulup grafiklersek yolu göstermiş oluruz. ID kullanıp enlem/boylam almak için bir fonksiyon yazalım, ve çevrimi yapalım,

import sqlite3

def get_osm_info(osmid):
    conn = sqlite3.connect(dbfile)
    sql = "select lat,lon from osm_nodes where id==?"
    c = conn.cursor()
    rows = list(c.execute(sql,(osmid,)))
    if (len(rows)==1): return rows[0]
    else: return None

coords = [get_osm_info(x) for x in path]

print (coords)
[(-4.70279, 55.48997), (-4.70551, 55.48911), (-4.70569, 55.48953), (-4.70713, 55.48649), (-4.70891, 55.48444), (-4.709, 55.48432), (-4.70993, 55.48329), (-4.71002, 55.48308), (-4.71015, 55.4822), (-4.7085, 55.48134), (-4.7061, 55.47901), (-4.70493, 55.47804), (-4.70463, 55.4777), (-4.70448, 55.47744), (-4.70366, 55.47573), (-4.70364, 55.47552), (-4.70336, 55.47385), (-4.70314, 55.47369), (-4.70277, 55.47336), (-4.70254, 55.47284), (-4.70254, 55.47236), (-4.70209, 55.47161), (-4.70093, 55.47065), (-4.70084, 55.47013), (-4.69627, 55.46672), (-4.69475, 55.46378), (-4.69253, 55.45959), (-4.69241, 55.4595), (-4.69203, 55.45931), (-4.68996, 55.45863), (-4.68702, 55.45828), (-4.68583, 55.45778), (-4.68453, 55.45697), (-4.68374, 55.45639), (-4.6831, 55.45592), (-4.68242, 55.45582), (-4.68233, 55.45581), (-4.67872, 55.45434), (-4.67745, 55.45379), (-4.67726, 55.45357), (-4.67709, 55.4533), (-4.67696, 55.45289), (-4.67691, 55.45276), (-4.67621, 55.45166), (-4.67587, 55.44956), (-4.67601, 55.44818), (-4.6759, 55.44788), (-4.67579, 55.44757), (-4.67471, 55.44478), (-4.6757, 55.44405), (-4.67501, 55.44215), (-4.67492, 55.44157), (-4.67399, 55.43991), (-4.67281, 55.4375), (-4.67236, 55.43661), (-4.67216, 55.43551), (-4.66865, 55.43009), (-4.66709, 55.42912), (-4.66642, 55.41922), (-4.666, 55.41862), (-4.66509, 55.41826), (-4.66414, 55.41694), (-4.66352, 55.4152), (-4.66316, 55.41373), (-4.6613, 55.41093), (-4.65905, 55.4105), (-4.65834, 55.41054), (-4.65791, 55.41036), (-4.65772, 55.41026), (-4.65711, 55.40959), (-4.65512, 55.40988), (-4.6484, 55.41474), (-4.63796, 55.40797), (-4.63801, 55.40781)]

Artık bu kordinatları bir haritada gösterebiliriz,

import folium
m = folium.Map(location=fr, zoom_start=12)
folium.PolyLine(locations=coords, color="red").add_to(m)
m.save("seychelles-route.html")

Sonuç

Yol üstteki haritada gösteriliyor. Kısa bir yol. Google yol tarifi algoritmasının bulduğu sonuç şurada. İkisi de kullanışlı duruyor.

Gösterilen teknolojiler, tasarım seçimleri sayesinde açık kaynak verisi OSM ile hızlı bir şekilde ürettiğimiz SQL tabanı ve diskdict sözlüğü ile direk disk bazlı hızlı kısa yol hesabı yapabiliyoruz. İşin en iyi tarafı Djikstra kısa yol algoritması üzerinde hiçbir değişiklik yapmadan onu olduğu gibi işletebilmemiz, çünkü onun farzettiği sözlük yapısına uygun bir kod sağladık ve algoritma direk çalıştı. Kodlar az hafıza gerektiriyor çünkü veri erişimini çoğu yerde noktasal atış, direk kimlik bazlı erişime indirgedik. Üstteki tabanları daha büyük haritalar üzerine işletince çıktının çok yer tutmadığını görebiliriz, mesela TR için diskdict tabanı 300 MB'dan daha az. Ayrıca erişim disk bazlı olduğu için tüm taban hafızaya taşınmayacak, gerekli yerlerine erişim yapılacak.

Not: Yolu sadece bir düğümler serisi olarak gösterdik; nihai bir ürün için yol parça kordinatlarını kenar objelerinin kendisinden almak daha iyi olur, bu bilgi nodes.csv içinde her kenar için mevcut zaten, bir LINESTRING olarak belirtiliyor. Bu bilgi veri hazırlama evresinde length ile beraber diskdict içine yazılabilir, ya da ayrı bir veri tabanında tutulup id ile sorgulanabilir. İşin bu kısmını okuyucuya bırakıyoruz.

Not: Mesafe hesabı olarak cdist kullanımı var, bu hesap bilindiği gibi Öklitsel mesafe hesaplar, yani $\sqrt{\Delta x^2 - \Delta y^2}$. Muhakkak enlem, boylam açısal değerlerdir, tam anlamıyla iki boyutlu bir düzlem üzerindeki x,y değerleri değillerdir, fakat kısa mesafelerde, aynen boylam=x ve enlem=y deyip kabaca bir grafikleme yapabildiğimiz gibi, burada da sınırlı bir alan içinde benzer bir yaklaşıksal hesap yapabiliyoruz. Ayrıca dikkat, hiçbir yerde cdist sonucunu gerçek bir mesafe ile karşılaştırmıyoruz, "acaba 10 km'den küçük mü büyük mü" gibi, onu sadece belli bir liste içindeki noktalardan en yakınını bulmak için kullandık.

Kaynaklar

[1] GEOFabrik

[2] Yol Tarifi, Harita Bilgisi: osrm-backend

[3] En Kısa Yol Algoritması, Yol Ağı, OSMNX

[4] https://github.com/Tristramg/osm4routing2

[5] Rust

[6] Python Sözlük (Dictionary) Veri Yapısı

[7] Dijkstra Algoritması ile En Kısa Yol

[8] University of California Bilgisayar Bilim Kodları


Yukarı