Ocasionalmente, puede encontrarse con la necesidad de realizar funciones geoespaciales dentro de su aplicación, como mapear ubicaciones de usuarios o analizar datos geográficos. Existen numerosas bibliotecas específicas de idiomas disponibles para estas tareas, como GDAL, Shapely y Geopandas para Python.
Alternativamente, la funcionalidad geoespacial se puede implementar a través de bases de datos; por ejemplo, puede usar la extensión PostGIS con una base de datos relacional como PostgreSQL o aprovechar la compatibilidad nativa con tipos de datos espaciales en una base de datos distribuida como Azure CosmosDB.
Sin embargo, si su almacenamiento backend, como Redis o Google Spanner, no admite consultas espaciales de forma nativa y necesita manejar consultas geoespaciales a gran escala, este artículo está diseñado para usted.
Siempre puedes crear otro microservicio para manejar datos espaciales, pero esta opción a menudo implica la sobrecarga de mantener una aplicación adicional. Otro enfoque es utilizar bibliotecas de indexación geográfica como S2 y H3. S2, desarrollado por Google, se basa en la curva de Hilbert, mientras que H3, desarrollado por Uber, se basa en un sistema de red global discreto geodésico. S2 y H3 comparten muchas similitudes: ambos dividen una región determinada en celdas y utilizan números enteros de 64 bits para indexar estas celdas.
Sin embargo, la principal diferencia radica en la forma de las células; S2 usa celdas de forma cuadrada, mientras que H3 usa celdas de forma hexagonal. Para algunas aplicaciones, H3 podría ofrecer un mejor rendimiento. Sin embargo, en general, cualquiera de las bibliotecas debería ser suficiente. En este artículo usaremos S2, pero puedes realizar funciones similares usando H3.
Niveles de celda: la jerarquía permite diferentes niveles de detalle, desde grandes regiones hasta pequeñas áreas precisas. Cada nivel representa una resolución diferente:
Nivel 0: Las células más grandes, que cubren una porción importante de la superficie terrestre.
Niveles superiores: las células se subdividen progresivamente en cuadrantes más pequeños. Por ejemplo, las celdas del Nivel 1 se dividen cada una en cuatro celdas del Nivel 2, y así sucesivamente.
Resolución y área: los niveles más altos corresponden a resoluciones más finas y áreas de celda más pequeñas. Esta jerarquía permite una indexación y consultas precisas en distintos niveles de detalle.
La siguiente tabla muestra varios niveles de celda junto con sus áreas correspondientes.
nivel | área mínima | área máxima | área promedio | unidades | Número de celdas |
---|---|---|---|---|---|
00 | 85011012.19 | 85011012.19 | 85011012.19 | km2 | 6 |
01 | 21252753.05 | 21252753.05 | 21252753.05 | km2 | 24 |
02 | 4919708.23 | 6026521.16 | 5313188.26 | km2 | 96 |
03 | 1055377.48 | 1646455.50 | 1328297.07 | km2 | 384 |
04 | 231564.06 | 413918.15 | 332074.27 | km2 | 1536 |
05 | 53798.67 | 104297.91 | 83018.57 | km2 | 6K |
06 | 12948.81 | 26113.30 | 20754.64 | km2 | 24K |
07 | 3175.44 | 6529.09 | 5188.66 | km2 | 98K |
08 | 786.20 | 1632.45 | 1297.17 | km2 | 393K |
09 | 195,59 | 408.12 | 324,29 | km2 | 1573K |
10 | 48,78 | 102.03 | 81.07 | km2 | 6M |
11 | 12.18 | 25,51 | 20.27 | km2 | 25M |
12 | 3.04 | 6.38 | 5.07 | km2 | 100M |
13 | 0,76 | 1,59 | 1.27 | km2 | 402M |
14 | 0,19 | 0,40 | 0,32 | km2 | 1610M |
15 | 47520.30 | 99638.93 | 79172.67 | m2 | 6B |
dieciséis | 11880.08 | 24909.73 | 19793.17 | m2 | 25B |
17 | 2970.02 | 6227.43 | 4948.29 | m2 | 103B |
18 | 742.50 | 1556.86 | 1237.07 | m2 | 412B |
19 | 185,63 | 389.21 | 309.27 | m2 | 1649B |
20 | 46,41 | 97,30 | 77,32 | m2 | 7T |
21 | 11.60 | 24.33 | 19.33 | m2 | 26T |
22 | 2.90 | 6.08 | 4.83 | m2 | 105T |
23 | 0,73 | 1,52 | 1.21 | m2 | 422T |
24 | 0,18 | 0,38 | 0,30 | m2 | 1689T |
25 | 453.19 | 950.23 | 755.05 | cm2 | 7e15 |
26 | 113.30 | 237,56 | 188,76 | cm2 | 27e15 |
27 | 28.32 | 59,39 | 47.19 | cm2 | 108e15 |
28 | 7.08 | 14,85 | 11.80 | cm2 | 432e15 |
29 | 1,77 | 3.71 | 2,95 | cm2 | 1729e15 |
30 | 0,44 | 0,93 | 0,74 | cm2 | 7e18 |
De la tabla proporcionada, es evidente que puede lograr una precisión de mapeo de hasta 0,44 cm^2 usando S2. Dentro de cada cuadrado de una celda S2, existe una celda secundaria que comparte el mismo padre, lo que indica una estructura jerárquica. El nivel de la celda puede ser un valor estático (el mismo nivel se aplica a todas las celdas) o puede ser dinámico donde S2 decide qué resolución funciona mejor.
Comencemos con un ejemplo. Considere que estamos escribiendo una aplicación que proporciona funciones similares a un servicio de proximidad para el área de Seattle. Queremos devolver una lista de cafeterías dentro de la vecindad indicada. Para realizar estas operaciones dividiremos esta tarea en 4 subtareas:
Para cargar un mapa de Google, usaríamos la biblioteca gmplot. Esta biblioteca requiere una clave API de Google Maps para cargarse. Para generar la clave API, siga las instrucciones aquí .
import gmplot import const # plot seattle with zoom level 13 gmap = gmplot.GoogleMapPlotter(47.6061, -122.3328, 13, apikey=const.API_KEY) # Draw the map to an HTML file: gmap.draw('map.html')
El código anterior genera un archivo map.html como se muestra a continuación:
Ahora que tenemos el mapa, dibujemos algunas celdas S2 para mapas:
from s2 import * import gmplot # plot seattle with zoom level 13 gmap = gmplot.GoogleMapPlotter(47.6061, -122.3328, 13, apikey=const.API_KEY) areatobeplotted = [ (47.64395531736767,-122.43597221319135), (47.51369277846956,-122.43597221319135), (47.51369277846956,-122.24156866779164), (47.64395531736767,-122.24156866779164), (47.64395531736767,-122.43597221319135) ] region_rect = S2LatLngRect( S2LatLng.FromDegrees(47.51369277846956,-122.43597221319135), S2LatLng.FromDegrees(47.64395531736767, -122.24156866779164)) coverer = S2RegionCoverer() coverer.set_min_level(8) coverer.set_max_level(15) covering = coverer.GetCovering(region_rect) geoms = 0 for cellid in covering: new_cell = S2Cell(cellid) vertices = [] for i in range(0, 4): vertex = new_cell.GetVertex(i) latlng = S2LatLng(vertex) vertices.append((latlng.lat().degrees(), latlng.lng().degrees())) gmap.polygon(*zip(*vertices), face_color='pink', edge_color='cornflowerblue', edge_width=5) geoms+=1 gmap.polygon(*zip(*areatobeplotted), face_color='red', edge_color='green', edge_width=5) print(f"Total Geometries: {geoms}") gmap.draw('/tmp/map.html')
Output: Total Geometries: 273
En el código anterior, primero centramos el trazador de Google Map alrededor del área de Seattle. En S2RegionCoverer
, inicializamos el cubridor de región para que tenga niveles dinámicos entre un nivel mínimo de 8 y un nivel máximo de 15. Esto permite que S2 ajuste dinámicamente todas las celdas en tamaños de celda específicos para lograr el mejor ajuste. El método GetCovering
devuelve la cobertura de un rectángulo alrededor del área de Seattle.
Luego, iteramos sobre cada celda, calculamos los vértices de las celdas y los trazamos en el mapa. Mantenemos el recuento de celdas generadas en alrededor de 273. Finalmente, trazamos el rectángulo de entrada en rojo. Este código trazará las celdas S2 en el mapa de Seattle en /tmp/map.html
, como se muestra a continuación:
Generemos una base de datos de cafeterías junto con sus identificadores de celda S2. Puede almacenar estas celdas en cualquier base de datos de su elección. Para este tutorial, usaremos una base de datos de datos SQLite. En el siguiente código de ejemplo, nos conectamos a la base de datos SQLite para crear una tabla CoffeeShops
con 3 campos Id
, name
y cell_id
.
De manera similar al ejemplo anterior, usamos S2RegionCoverer
para calcular las celdas, pero esta vez usamos un nivel fijo para trazar puntos. Finalmente, la ID calculada se convierte en una cadena y se almacena en la base de datos.
import sqlite3 from s2 import S2CellId,S2LatLng,S2RegionCoverer # Connect to SQLite database conn = sqlite3.connect('/tmp/sqlite_cells.db') cursor = conn.cursor() # Create a table to store cell IDs cursor.execute('''CREATE TABLE IF NOT EXISTS CoffeeShops ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, cell_id TEXT )''') coverer = S2RegionCoverer() # Function to generate S2 cell ID for a given latitude and longitude def generate_cell_id(latitude, longitude, level=16): cell=S2CellId(S2LatLng.FromDegrees(latitude, longitude)) return str(cell.parent(level)) # Function to insert cell IDs into the database def insert_cell_ids(name,lat,lng): cell_id = generate_cell_id(lat, lng) cursor.execute("INSERT INTO CoffeeShops (name, cell_id) VALUES (?, ?)", (name, cell_id)) conn.commit() # Insert cell IDs into the database insert_cell_ids("Overcast Coffee", 47.616656277302155, -122.31156460382837) insert_cell_ids("Seattle Sunshine", 47.67366852914391, -122.29051997415843) insert_cell_ids("Sip House", 47.6682364706238, -122.31328618043693) insert_cell_ids("Victoria Coffee",47.624408595334536, -122.3117362652041) # Close connection conn.close()
En este punto, tenemos una base de datos que almacena cafeterías junto con sus ID de celda, determinados por la resolución seleccionada para el nivel de celda.
Finalmente, busquemos cafeterías en el área del Distrito Universitario.
import sqlite3 from s2 import S2RegionCoverer,S2LatLngRect, S2LatLng # Connect to SQLite database conn = sqlite3.connect('/tmp/sqlite_cells.db') cursor = conn.cursor() # Function to query database for cells intersecting with the given polygon def query_intersecting_cells(start_x,start_y,end_x,end_y): # Create S2RegionCoverer region_rect = S2LatLngRect( S2LatLng.FromDegrees(start_x,start_y), S2LatLng.FromDegrees(end_x,end_y)) coverer = S2RegionCoverer() coverer.set_min_level(8) coverer.set_max_level(15) covering = coverer.GetCovering(region_rect) # Query for intersecting cells intersecting_cells = set() for cell_id in covering: cursor.execute("SELECT name FROM CoffeeShops WHERE cell_id >= ? and cell_id<=?", (str(cell_id.range_min()),str(cell_id.range_max()),)) intersecting_cells.update(cursor.fetchall()) return intersecting_cells # Query for intersecting cells intersecting_cells = query_intersecting_cells(47.6527847,-122.3286438,47.6782181, -122.2797203) # Print intersecting cells print("Intersecting cells:") for cell_id in intersecting_cells: print(cell_id[0]) # Close connection conn.close()
Output: Intersecting cells: Sip House Seattle Sunshine
A continuación se muestra una representación visual de las celdas. Para mantener la brevedad, no se agrega el código de visualización que se muestra a continuación.
Dado que todas las celdas secundarias y principales comparten un prefijo, podemos consultar rangos de celdas entre el mínimo y el máximo para obtener todas las celdas entre esos dos valores. En nuestro ejemplo, utilizamos el mismo principio para consultar la cafetería.
En este artículo, hemos demostrado cómo utilizar la indexación geográfica para almacenar y consultar datos geoespaciales en bases de datos que no admiten consultas geoespaciales. Esto se puede ampliar aún más a múltiples casos de uso, como calcular la ruta entre dos puntos o encontrar los vecinos más cercanos.
Normalmente, para consultas de bases de datos geoindexadas, tendría que realizar un posprocesamiento adicional de los datos. Es necesario considerar cuidadosamente la lógica de consulta y posprocesamiento para garantizar que no abrumemos el nodo.