1. 关于Ganos
Ganos是阿里云数据库产品事业部联合阿里云飞天数据库与存储实验室联合共同研发的新一代云原生位置智能引擎,它将时空数据处理能力融入了云原生关系型数据库PolarDB、云原生多模数据库Lindorm、云原生数据仓库AnalyticDB和云数据库RDS PG等核心产品中。Ganos目前拥有几何、栅格、轨迹、表面网格、体网格、3D实景、点云、路径、地理网格、快显十大核心引擎,为数据库构建了面向新型多模多态时空数据的存储、查询、分析、服务等一体化能力。
本文介绍的地理网格引擎能力,依托阿里云云原生关系型数据库PolarDB建设输出。
2. 关于Ganos H3地理网格
地理网格是一种再现地球表面的多边形网格单元集合,可以用于表示地物在地理空间中的位置信息,融合其他各类时空数据。地理网格计算一般由粗到细,逐级地分割地球表面,将地球曲面用一定大小的多边形网格进行近似模拟,其目标是将地理空间的定位和地理特征的描述一体化,并将误差范围控制在网格单元的范围内。每个网格单元都会进行编码,网格与编码是一一对应的。三维地理网格不只考虑经纬度,还把高度维纳入剖分和编码范围。
Ganos地理网格引擎目前涵盖GeoSOT和H3两种地理网格。GeoSOT是我国提出的一套地球空间剖分理论,并在此基础上发展出的一种离散化、多尺度区域位置标识体系(关于GeoSOT网格的最佳实践可参考Ganos地理网格引擎支撑无人机路径规划能力实践)。H3是Uber研发的一种覆盖全球表面的二维地理网格,采用了一种全球统一的、多层次的六边形网格体系来表示地球表面。H3设计独特之处在于其采用六边形结构,相较于传统的四边形或三角形网格,六边形网格具有更均匀分布、邻居关系固定且无方向性等优点,这使得在进行空间数据分析、路径规划、地理编码以及地理围栏等领域时,能够更加精确和高效地组织和查询地理空间数据。
利用Ganos地理网格的函数可以将不同的空间范围转换为网格编码,可以求出网格编码的空间范围、层级和父子网格。Ganos支持退化网格计算(如下图),即充分利用网格的层级关系,用更精简的网格组合对空间范围进行表达。此外,Ganos自研地理网格索引,可用于高效查询网格码以及加速聚合计算。
3. Ganos H3地理网格能力解析
3.1 业务场景
H3 地理网格技术在诸多业务场景中得到广泛应用,主要包括:
- 物流与出行服务:基于地理网格开展路线规划、区域覆盖分析、配送范围界定、热点区域发现等功能建设;
- 数据分析:基于地理网格开展人口密度分析、移动用户行为分析、地理市场细分等大数据分析领域;
- 物联网(IoT):面向智能城市、环境监测、资产追踪等需要实时监控的数据,基于地理网格进行监测数据空间分布分析的场景;
- 社交网络:基于地理网格构建面向位置服务(LBS)、好友位置分享、事件通知等社交场景的应用;
- 应急响应与公共服务:基于地理网格开展灾害分布分析、灾害预警热力、应急资源分布、紧急救援区域划分等;
总之,H3 网格技术为企业和开发者提供了一种强大的工具,能够更好地管理和利用地理空间数据,提高与位置相关的决策效率和准确性。
3.2 能力解析
Ganos H3地理网格包含网格输入/输出,网格父子关系判断,网格路径分析,网格查询等多种能力,地理网格还支持转为Ganos Geometry类型,与其它类型的空间数据进行联合分析。值得强调的是,Ganos H3地理网格也支持退化,用更精简的网格组合对空间范围进行表达,降低用户因打码带来的数据库存储成本。
关于Ganos H3地理网格详细功能,可参见 Ganos地理网格用户手册。
4. 最佳实践
下面我们使用真实场景数据来介绍如何使用Ganos H3进行空间点数据的入库、打码、查询到最终显示等功能。这里我们选择的测试数据是Uber发布的2023年纽约出租车位置数据集FOIL进行测试。FOIL数据记录了纽约地区所有的出租车上下车的位置数据,详细信息参考。
4.1 数据导入
在使用Ganos H3前,需要先创建GeomGrid扩展,SQL如下:
CREATE EXTENSION Ganos_GeomGrid CASCADE;
GeomGrid中提供了h3grid字段类型,用于表示H3编码。下面的SQL语句创建了一个带有h3grid类型的数据表FOIL2013,这里字段h3_lev13代表我们使用的是第13层级的H3编码。H3不同层级网格具有不同分辨率,用户可以可以更具具体业务需求灵活定义。H3各个层级对应的空间分辨率请参考
-- 创建表用来保存foil点数据,h3_lev13代表13级编码 CREATE TABLE FOIL2013 ( id text, lon float, lat float, h3_lev13 h3grid);
FOIL文件是以csv文件格式保存的。用户可以通过编程方式从CSV中提取相关信息通过SQL入库,也可以通过FDW方式入库,这里我们使用Ganos FDW模块通过FDW方式实现数据快速入库。
首先我们把目标文件上传到oss指定目录,比如这里我们文件路径为:
- endpoint:oss-cn-hangzhou-internal.aliyuncs.com
- bucket:dla-ganos-hz
- path:FOIL/trip_data_1.csv
首先我们创建Ganos FDW扩展
CREATE EXTENSION ganos_fdw CASCADE;
然后我们需要创建一个csvserver,负责管理csv文件,ak_id和ak_secret就是用户的OSS连接AK的id和secret信息,format为'CSV'代表管理的数据格式为CSV。
CREATE SERVER csvserver FOREIGN DATA WRAPPER ganos_fdw OPTIONS ( datasource 'OSS://<ak_id>@oss-cn-hangzhou-internal.aliyuncs.com/dla-ganos-hz/FOIL/trip_data_1.csv', format 'CSV' ); CREATE USER MAPPING FOR CURRENT_USER SERVER csvserver OPTIONS (user '<ak_id>', password '<ak_secret>');
FDW服务创建成功后,我们就可以通过外表的形式,将OSS上的csv映射到数据库中作为一个普通表进行查询,详细SQL语句如下。这里我们只选择medallion、pickup_longitude、pickup_latitude三列数据,映射的外表名称为trip_data_1:
CREATE FOREIGN TABLE trip_data_1 ( medallion varchar, pickup_longitude varchar, pickup_latitude varchar) SERVER csvserver OPTIONS ( layer 'trip_data_1' );
查询外表trip_data_1:
SELECT * FROM trip_data_1;
结果如下:
然后将外表数据导入到前面我们创建的FOIL3表中:
INSERT INTO FOIL2013 SELECT medallion as id ,cast (pickup_longitude as double precision) as lon, cast(pickup_latitude as double precision) as lat FROM trip_data_1;
查询FOIL2013,可以看到CSV上的信息已经成功导入到FOIL2013表格中,后面我们就可以根据经纬度等位置信息进行打码了。
SELECT * FROM FOIL2013;
4.2 对象打码
数据入库后,我们可以对点数据进行打码。Ganos H3提供了多种编码方式,比如通过指定经纬度、标准H3字符串、Integer类型H3编码、二进制类型H3编码,以及直接从Point类型转换为H3等方式。详细内容请参考文档:ST_H3FromText,ST_H3FromInt,ST_AsH3Grid,ST_H3FromLatLng等。
这里我们使用的是ST_H3FromLatLng函数,用户通过指定将经纬度和目标层级可以直接获得H3编码。比入下面SQL语句就是从FOIL表中的lat和lon字段,生成第13层级的H3编码,并保存在h3_lev13列中,并通过ST_AsText函数查询具体h3编码。
-- Level 13 UPDATE FOIL2013 SET h3_lev13 = ST_H3FromLatLng(lat,lon,13); --查询 SELECT id,lon,lat,ST_AsText(h3_lev13) as h3 FROM FOIL2013 LIMIT 100;
查询结果如下图:
4.3 网格聚合
使用网格的一个典型场景就是对空间数据按照格网码进行空间聚合统计分析,从而获取热力图等专题地图信息。比如下面我们就是根据h3_lev13列中的h3编码,从FOIL23表格中统计出每个网格内的点数量(count(*)):
--按照h3_lev13进行统计 CREATE TABLE h3_count_lev13 AS SELECT ST_AsText(h3_lev13) AS h3code,count(*) FROM FOIL2013 GROUP BY h3_lev13; -- 查询统计结果 SELECT ST_AsText(h3_lev13), ST_AsText(geometry),count FROM h3_count_lev13 order by count desc;
输出结果:
4.4 网格查询
Ganos H3提供了多种基于H3编码的操作,比如下面我们可以通过ST_GridDistance方法获取FOIL23中所有的与空间位置(40.71481749,-73.99100368)对应格网的距离小于10的格网点:
SELECT * FROM foil2013 WHERE ST_GridDistance(ST_H3FromLatLng(40.71481749,-73.99100368,13),h3_lev13)<10;
输出结果如下:
4.5 网格可视化
Ganos支持像几何数据一样可视化H3网格,即可以把H3网格转化为矢量瓦片,再由前端渲染查看。Ganos提供了原生H3网格MVT函数和索引,用户可以很便捷地查询和可视化H3网格及其包含的统计信息。值得一提的是,Ganos支持可视化动态生成的H3网格,比如用户想可视化层级为10的H3网格,但是表格中并没有保存层级为10的H3网格,此时可以使用ST_AsMVT加ST_AsMVTGeom(ST_H3FromLatLng(lat, lon, 10), ...)的命令来动态生成层级为10的H3网格的可视化结果,但是在效率上会不如事先保存H3网格,因此下面主要介绍如何可视化保存在表格中的H3网格。
创建H3网格的索引SQL指令如下(创建索引对可视化并不是必须的,但能有效提升可视化效率):
CREATE INDEX ON h3_count_lev13 USING GIST(h3_lev13);
下面的SQL指令是根据H3网格获取编号为(14,4826,6157)的矢量瓦片,该指令和获取基于矢量数据的矢量瓦片的指令基本一致。
SELECT ST_AsMVT(tile) FROM (SELECT ST_AsMVTGeom(h3_lev13, ST_Transform(ST_TileEnvelope(14, 4826, 6157), 4326)) AS grid, count FROM h3_count_lev13 WHERE h3_lev13 && ST_Transform(ST_TileEnvelope(14, 4826, 6157), 4326)) AS tile;
下面的动图展示了在前端实时渲染在数据库端动态查询H3网格的矢量瓦片的结果,网格的颜色是根据网格对应的统计值动态决定的。前端部分只需要一个Python脚本和一个HTML文件,启动时只需要运行该Python脚本和打开浏览器输入localhost:5100即可看到结果。Python脚本会根据用户鼠标在地图上的位置和缩放层级自动生成相应的SQL查询发送给数据库,然后将数据库的查询结果显示在网页上。前端的具体代码见附录部分。
5. 技术优势
相比pg-h3等开源产品,Ganos H3具有如下技术优势:
- 支持更加丰富的打码方式,比如用户可以直接将Ganos的点、线、面类型转换为H3编码
- Ganos H3在打码效率和格网查询效率上都进行了大量性能优化。
- Ganos H3支持与其他Ganos模型实现联合查询分析,比支持将几何(geometry)类型直接转换为h3编码,或者使用h3与栅格(raster)模型进行基于格网的像素统计等。
- Ganos H3基于PolarDB底层的多态分层存储,可以实现基于OSS的海量数据点的打码与存储,显著降低存储成本。
6. 总结
本文重点介绍了Ganos H3地理网格的相关能力,并基于一个最佳实践介绍了如何使用H3地理网格进行矢量数据的聚合、空间关系判断与可视化。地理网格是移动对象相关应用场景的重要支撑,它与轨迹、矢量、栅格等数据类型融合之后可以带来十分巨大的业务价值与想象空间。Ganos作为全球首个支持移动对象(MOD)的数据库,相关能力已经在交通、物流、出行、汽车等多个客户侧得到有效验证。相较于传统中间键或业务代码实现的方式,Ganos从数据库系统最底层为大规模移动对象提供时空处理框架,计算效率与综合成本均有大规模改善。未来Ganos还将提供更多高效的面向移动对象的场景的库内原生分析能力,推动相关领域的空间信息应用全面走向“在线化”。
7. 附录
可视化前端Python脚本如下:
from quart import Quart, send_file, render_template import asyncpg import io import re # 数据库连接参数 CONNECTION = {"host": "YOUR-HOST-NAME-OR-IP", "port": PORT_NO, "database": "DATABASE_NAME", "user": "USER_NAME", "password": "PASSWORD"} # 目标表名/字段/ID TABLE = "h3_count_lev13" H3_COL = "h3_lev13" H3_GEOM_COL = "geometry" AGG_VAL_COL = "count" COL_SRID = 4326 app = Quart(__name__, template_folder='./') before_serving .async def create_db_pool(): app.db_pool = await asyncpg.create_pool(**CONNECTION) after_serving .async def close_db_pool(): await app.db_pool.close() route("/") .async def home(): sql = f''' SELECT ST_Extent(ST_Transform(ST_Envelope({H3_GEOM_COL}), 4326)) FROM {TABLE}; ''' async with app.db_pool.acquire() as connection: box = await connection.fetchval(sql) box = re.findall('BOX\((.*?) (.*?),(.*?) (.*?)\)', box)[0] min_x, min_y, max_x, max_y = list(map(float, box)) bounds = [[min_x, min_y], [max_x, max_y]] center = [(min_x + max_x) / 2, (min_y + max_y) / 2] return await render_template('./index.html', center=str(center), bounds=str(bounds)) route("/h3_mvt/<int:z>/<int:x>/<int:y>") .async def h3_mvt(z, x, y): sql = f''' SELECT ST_AsMVT(tile.*) FROM (SELECT ST_AsMVTGeom({H3_COL}, ST_Transform(ST_TileEnvelope($1,$2,$3),{COL_SRID}), 4096, 512, true) geometry, {AGG_VAL_COL} count FROM {TABLE} WHERE ({H3_COL} && ST_Transform(ST_TileEnvelope($1,$2,$3),{COL_SRID}))) tile''' async with app.db_pool.acquire() as connection: tile = await connection.fetchval(sql, z, x, y) return await send_file(io.BytesIO(tile), mimetype='application/vnd.mapbox-vector-tile') if __name__ == "__main__": app.run(port=5100)
index.html文件内容如下:
<html> <head> <meta charset="utf-8"> <title>map viewer</title> <meta name="viewport" content="initial-scale=1,maximum-scale=1,user-scalable=no"> <link href="https://api.mapbox.com/mapbox-gl-js/v2.14.1/mapbox-gl.css" rel="stylesheet"> <script src="https://api.mapbox.com/mapbox-gl-js/v2.14.1/mapbox-gl.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/chroma-js/2.4.2/chroma.min.js"></script> </head> <body> <div id="map" style="position: absolute;left:0; top: 0; bottom: 0; width: 100%;cursor:pointer;"></div> <div class="counter" style="position: absolute;left:2%;font-size: 20px;padding: .1em .1em;text-shadow: 3px 3px 3px black;"> <span>当前网格计数:</span> <span id="count">0</span> </div> <script> let YOUR_TOKEN = "input your mapbox token" mapboxgl.accessToken = YOUR_TOKEN; const map = new mapboxgl.Map({ container: "map", style: "mapbox://styles/mapbox/navigation-night-v1", center: {{ center }}, zoom: 1 }) map.on("load", () => { map.fitBounds({{ bounds }}) map.on('mousemove', 'h3', (e) => { map.getCanvas().style.cursor = "default"; if (e.features.length > 0) document.getElementById('count').innerText = e.features[0].properties.count }) map.on('mouseleave', 'h3', () => { map.getCanvas().style.cursor = "grab"; document.getElementById('count').innerText = 0 }) map.addSource("h3_source", { type: "vector", tiles: [`${window.location.href}h3_mvt/{z}/{x}/{y}`], tileSize: 512 }); // make color map const MIN = 1 const MAX = 600 const STEP = 10 color_map = chroma.scale(["#536edb", "#5d96a5", "#68be70", "#91d54d", "#cddf37", "#fede28", "#fda938", "#fb7447", "#f75a40", "#f24734", "#e9352a", "#da2723", "#cb181d"]) .domain([MIN, MAX]); let colors = [] for (let i = MIN; i < MAX; i += STEP) colors.push(color_map(i).hex(), i) colors.push(color_map(MAX).hex()) map.addLayer({ id: "h3", type: "fill", source: "h3_source", "source-layer": "default", paint: { "fill-color": [ "step", ["get", "count"], colors ], "fill-opacity": 0.8 } }); }); </script> </body> </html>