Ganos矢量快显功能上手系列一:2D和3D矢量快显

Ganos的2D矢量快显功能有效解决了传统切片方案在切片时间和存储开销方面的主要问题,并支持局部更新。与现有系统相比,在效率、存储开销及功能丰富性方面均有显著提升。Ganos的3D矢量可视化功能通过对2D矢量切片的扩展,支持Geometry3D数据的可视化,能够有效地用于大范围3D场景的展示。本文以案例形式为您介绍如何创建和更新2D矢量金字塔,以及3D矢量物体可视化功能。

功能介绍

2D矢量快显

传统系统支持矢量数据可视化,主要采用离线切片的方式。当可视化请求到达时,将预先构建的切片经过简单处理后返回客户端。这种处理方案存在两大痛点:一是“慢”,在大规模数据集上运行离线切片通常需要十几个小时甚至几天的时间才能完成。二是“大”,在最高支持16级缩放的地图服务中,需要事先存储几十亿个切片,导致存储开销极大。另外,由于“慢”这一问题,衍生出对数据更新不友好的新问题。该问题在数据管理能力较弱的GIS系统中尤为凸显。由于缺乏对数据更新内容及其对切片影响范围的感知,一旦发生数据更新,即使只是局部的小范围更新,也只能通过耗时费力的全盘重建切片的方式来解决。

Ganos快显引擎所提供的2D矢量快显功能,专门针对亿级二维矢量数据(包括点、线、面)进行可视化处理。该功能能够很好地解决以上问题,Ganos创新性地提出了稀疏金字塔索引,跳过了对数据稀疏区域的切片构建,结合数据库查询优化,并通过视觉可见性剔除算法,过滤掉大量不影响显示效果的数据,从而有效解决了切片时间漫长和存储开销巨大的两大痛点。不仅如此,Ganos还支持稀疏金字塔的动态更新。当矢量数据在小范围内发生局部更新时,Ganos能够自动识别出受影响的切片,并将切片的更新限制在尽可能小的范围内,从而避免了对稀疏金字塔进行重建的需要,显著提高了更新效率。经实测,在一台配置普通的8 核PolarDB PostgreSQL版集群上,构建包含七千万条房屋数据集的稀疏金字塔仅需6分钟。当发生超过一百万条数据更新时,稀疏金字塔的更新也仅需不到1分钟。在响应可视化请求时,平均返回一个切片的时间不到1毫秒。具备如此高效的性能的基础上,所需的磁盘存储空间仅约为3 GB。

2

3D矢量可视化

Ganos不仅支持2D矢量快速显示,还与阿里云DataV数据可视化产品团队联合开发了对3D矢量数据可视化的支持。通过扩展现有的2D矢量切片标准,Ganos与DataV合作实现了可用于3D矢量物体可视化的3D矢量切片,效果图如下:

2

使用步骤

2D矢量快显使用步骤

准备数据表

准备包含Geometry属性的数据表。Ganos提供多种将矢量数据写入数据表的函数,包括常见的ST_GeomFromText、ST_GeomFromWKT和ST_GeomFromWKB等。使用Ganos的矢量快显功能,仅需确保数据表包含主键ID及Geometry属性列。

  1. 在使用Ganos的快显功能之前,请安装快显功能插件ganos_geometry_pyramid。

    CREATE EXTENSIION ganos_geometry_pyramid CASCADE;
  2. 创建一个只包含主键id和geom列的数据表。

    CREATE TABLE try_ganos_viz(id SERIAL NOT NULL, geom Geometry);
  3. 使用脚本或其它工具将一个矢量数据集导入该表,只需要该脚本或工具能够生成类似如下的SQL命令即可(假设数据集文件存储的是4326坐标系的WKT格式数据)。

    INSERT INTO try_ganos_viz(geom) VALUES (ST_GeomFromText('WKT FORMAT TEXT', 4326));

    以上SQL语句将数据以4326坐标系格式存储到数据库中,您也可以指定存储为其他坐标系格式。

  4. 导入所有数据后,对Geometry属性列构建一个空间索引。

    CREATE INDEX ON try_ganos_viz USING gist(geom);

构建稀疏金字塔

ST_BuildPyramid

构建稀疏金字塔功能已经封装至ST_BuildPyramid函数,详情请参考ST_BuildPyramid。以下将通过示例形式进行详细介绍。

  • 创建一个使用默认参数配置的稀疏金字塔。

    SELECT ST_BuildPyramid('try_ganos_viz', 'geom', 'id', '');
  • 在config参数中指定构建稀疏金字塔时的并行度,执行如下命令以32并行度构建矢量金字塔:

    SELECT ST_BuildPyramid('try_ganos_viz', 'geom', 'id', '{"parallel":32}');
  • 在config参数中指定“tileSize”和“tileExtend”的值,以定义切片的像素大小和裁切范围。

    SELECT ST_BuildPyramid('try_ganos_viz', 'geom', 'id', '{"parallel":32, "tileSize":4096, "tileExtend":128}');
  • Ganos允许根据对性能和存储开销的综合考量,使用“maxLevel”和“splitSize”参数控制稀疏金字塔结构。

    • 相较于存储开销,更加重视查询性能。可以通过设置较小的“splitSize”值(如果一个切片中的要素小于splitSize,则该切片将在查询时动态构建),以确保尽可能多的切片在查询前已被构建。

    • 若更加关注存储开销,除了可以设置较大的“splitSize”值外,还可以通过设置较小的“maxLevel”来控制金字塔的高度,从而减少需维护的切片数量。

    以下示例,构建了一个“maxLevel”值为10,“splitSize”值为1000的稀疏金字塔:

    SELECT ST_BuildPyramid('try_ganos_viz', 'geom', '{"maxLevel":10, "splitSize":1000}');
  • 提供“buildRules”参数以灵活控制稀疏金字塔的构造规则。例如,在层级小于等于5的切片中,仅可视化面积大于100的要素,可以使用如下命令:

    SELECT ST_BuildPyramid('try_ganos_viz', 'geom', '{"maxLevel":10, "buildRules":[
                           {"level":[0,1,2,3,4,5], "value":{"filter": "ST_Area(geom)>100"}}
    ]}');

    以上SQL语句中的“filter”所对应的过滤条件可以是任意SQL语句中位于WHERE子句后面的条件语句。

ST_BuildPyramidUseGeomSideLen

构建矢量金字塔还可以使用ST_BuildPyramidUseGeomSideLen函数。与ST_BuildPyramid相比,该函数进行了性能优化,能够在数据表中包含大量面积较小的要素时,有效提升稀疏金字塔的构建效率。详情请参考ST_BuildPyramidUseGeomSideLen

在使用ST_BuildPyramidUseGeomSideLen函数之前,数据表中需存在实数属性列,以记录geom列中ST_XMax(geom)-ST_XMin(geom)与ST_YMax(geom)-ST_YMin(geom)的较大值。同时,还需对该属性列建立一个索引。以上述的try_ganos_viz表为例,使用以下语句新增一个max_side_len属性列,并为该列构建一个B树索引:

ALTER TABLE try_ganos_viz
ADD COLUMN max_side_len DOUBLE PRECISION;

CREATE OR REPLACE FUNCTION add_max_len_values() RETURNS VOID AS $$
DECLARE
  t_curs CURSOR FOR
    SELECT * FROM try_ganos_viz;
  t_row usbf%ROWTYPE;
  gm GEOMETRY;
  x_min DOUBLE PRECISION;
  x_max DOUBLE PRECISION;
  y_min DOUBLE PRECISION;
  y_max DOUBLE PRECISION;
BEGIN
  FOR t_row IN t_curs LOOP
    SELECT t_row.geom INTO gm;
    SELECT ST_XMin(gm) INTO x_min;
    SELECT ST_XMax(gm) INTO x_max;
    SELECT ST_YMin(gm) INTO y_min;
    SELECT ST_YMax(gm) INTO y_max;
    UPDATE try_ganos_viz
      SET max_side_len = GREATEST(x_max - x_min, y_max - y_min)
    WHERE CURRENT OF t_curs;
  END LOOP;
END;
$$ LANGUAGE plpgsql;
SELECT add_max_len_values();

CREATE INDEX ON try_ganos_viz USING btree(max_side_len);

在调用ST_BuildPyramidUseGeomSideLen时,需要提供新增列的列名,其余参数与ST_BuildPyramid相同。执行以下语句,将对新增了max_side_len列的try_ganos_viz表构建稀疏金字塔:

SELECT ST_BuildPyramidUseGeomSideLen('try_ganos_viz', 'geom', 'max_side_len', 'id', '{"parallel":32}');

ST_BuildPyramidUseGeomSideLen同样支持多种配置参数,能够灵活调整稀疏金字塔的构建规则。

更新金字塔

当数据表中的数据发生更新时,Ganos提供ST_UpdatePyramid函数用于对金字塔进行更新,只需提供数据更新的外包框范围,详情请参考ST_UpdatePyramid

  • 假设在[(lon1, lat1), (lon2, lat2)]的经纬度范围内插入了多条数据,希望所有受影响的切片均被更新(假设maxLevel=16),可以执行以下语句:

    SELECT ST_UpdatePyramid('try_ganos_viz', 'geom', 'id', ST_MakeEnvelope(0,-10,20,30, 4326), '{"updateBoxScale":100000}');

    在上述SQL语句中假设lon1=0lat1=-10lon2=20lat2=30

  • 假设不希望进行全局大规模更新,可以指定一个较小的updateBoxScale参数值,以避免较小层级的切片也被更新。执行以下语句将仅更新范围稍大的切片及其层级以下的切片:

    SELECT ST_UpdatePyramid('try_ganos_viz', 'geom', 'id', ST_MakeEnvelope(0,-10,20,30, 4326), '{"updateBoxScale":2}');
说明
  • 调用ST_UpdatePyramid时,不需要指定并行度。该函数会自动采用调用ST_BuildPyramid或ST_BuildPyramidUseGeomSideLen时提供的并行值。

  • 由于更新金字塔涉及稀疏金字塔的更新、旧切片的删除,以及新切片的生成等步骤,当发生大范围的数据更新时,建议直接调用ST_BuildPyramid或ST_BuildPyramidUseGeomSideLen重建金字塔。

获取矢量切片

矢量切片具备保留要素信息的优势。在地图服务中,相较于传统栅格切片,矢量切片的多级缩放实现更加平滑的过渡,提供更好的视觉效果。Ganos提供ST_Tile函数,用于实时调用获取矢量切片。详情请参考ST_Tile

执行以下任一语句,以获取中国所在的切片:z=1,x=1,y=0。

SELECT ST_Tile('try_ganos_viz', '1_1_0');

SELECT ST_Tile('try_ganos_viz', 1, 0, 1);

获取栅格切片

Ganos同样支持当前仍被广泛使用的栅格切片。栅格切片是以图片形式呈现的切片,相较于矢量切片,栅格切片不支持客户端的动态渲染,因此对客户端系统的性能要求较低。Ganos提供了ST_AsPng函数,该函数允许在数据库端按需将矢量数据动态渲染为栅格切片,并将结果返回给客户端。此函数具备基本的栅格符号化能力,主要适用于不需复杂符号化的轻量级场景。详情请参考ST_AsPng

调用以下语句以返回切片编号为'1_1_0'的栅格切片,该切片将依据所提供的渲染参数进行渲染,并以PNG格式输出。

SELECT ST_AsPng('try_ganos_viz', '1_1_0', '{"point_size":5, "line_width":2, "line_color":"#003399FF", 
                "fill_color":"#6699CCCC", "background":"#FFFFFF00"}');

3D矢量可视化使用步骤

准备数据表

准备包含Geometry3D属性列的数据表。可通过ST_GeomFromText、ST_GeomFromWKT、ST_GeomFromWKB等函数实现数据的导入。

  1. 在使用Ganos的3D矢量可视化功能之前,请安装3D矢量可视化功能插件ganos_geometry。

    CREATE EXTENSIION ganos_geometry CASCADE;
  2. 创建只包含主键id和geom列的数据表。

    CREATE TABLE try_ganos_viz3d(id SERIAL NOT NULL, geom Geometry);

获取3D矢量切片

在使用3D矢量数据可视化功能之前,需要调用Ganos提供的两个函数:ST_AsMVTGeom3D和ST_AsMVT3D。

  • ST_AsMVTGeom3D

    ST_AsMVTGeom3D功能类似于PostGIS的ST_AsMVTGeom之于2D矢量数据,但是对其进行了扩展,能够将Geometry3D数据的坐标空间转换到MVT的坐标空间,如果有需要还会根据切片的外包框对Geometry3D数据进行裁切。详细函数介绍请参考ST_AsMVTGeom3D。使用示例如下:

    SELECT ST_AsText(ST_AsMVTGeom3D(ST_Transform('SRID=4326; LINESTRING(-10 -10 30, -10 -20 30)'::geometry, 3857), ST_TileEnvelope(1, 0, 0))) AS geom;

    返回结果如下:

                                            geom                                        
    ------------------------------------------------------------------------------------
     MULTILINESTRING Z ((3868.44444444444 4324.7197219642 30,3868.44444444444 4352 30))
    (1 row)
  • ST_AsMVT3D

    ST_AsMVT3D功能类似于PostGIS的ST_AsMVT之于2D矢量数据,将若干行数据封装进一个3D矢量切片中的聚合函数。每条被封装的数据均对应经过坐标空间转换为MVT坐标空间的Geometry3D数据,这些封装的数据共同形成一个切片图层。详细函数介绍请参考ST_AsMVT3D。使用示例如下:

    WITH mvtgeom AS
    (
      SELECT ST_AsMVTGeom3D(
        ST_Transform('SRID=4326; MULTIPOLYGON(((100 50 0, -100 50 1, -100 -50 2, 100 -50 3, 100 50 0)), ((0 0 0, 1 0 1, 2 2 2, 0 0 0)))'::geometry, 3857),
        ST_TileEnvelope(1, 0, 0)) AS geom,  'test' AS name
    )
    SELECT ST_AsMVT3D(mvtgeom.*) FROM mvtgeom;

    返回结果如下:

                                                                                                                         st_asmvt3d                                                                                                                     
    ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
     \x1a760a0764656661756c74125812020000180322500d8044a842b83116ff23d80105802400080f0d810481041d162e000e2e590e0f0dd920dc0405168024d70106c727f3160d0f0dc827f4160e1600f31615c72700080f0d0000001600cc1808c80300000f1a046e616d6522060a04746573742880207802
    (1 row)

最佳实践

本案例基于Ganos的2D矢量快显功能,为您介绍如何快速搭建一个Web地图服务。

全栈架构

此Web地图服务由数据库服务器、Python服务端和用户端三部分构成,其全栈架构示意如下图所示。

image

数据库

将地图数据导入数据库,并构建稀疏金字塔所需的索引。详细操作请参考2D矢量快显使用步骤

服务端代码

为实现代码的简洁性并更注重逻辑描述,本案例选用了Python作为后端语言,Web框架采用了基于Python的Flask框架,数据库连接框架则使用了基于Python的Psycopg2(可使用pip install psycopg2安装)。值得一提的是,当前已实现最基础的功能,当Web服务自身的性能遇到瓶颈,可依据不同的平台与框架进行优化,以获得更优的响应性能。首先在后端建立矢量金字塔,随后实现两个接口。其中,矢量瓦片接口使用points表中的数据,而栅格瓦片接口则使用buildings表中的数据,并定义相应的样式,供前端直接调用。为便于说明,后端代码同时提供了矢量和栅格两个接口,实际使用时可根据需求选择。

# -*- coding: utf-8 -*-
# @File : Vector.py

import json
from psycopg2 import pool
from threading import Semaphore
from flask import Flask, jsonify, Response, send_from_directory
import binascii

# 连接参数
CONNECTION = "dbname=<database_name> user=<user_name> password=<user_password> host=<host> port=<port>"


class ReallyThreadedConnectionPool(pool.ThreadedConnectionPool):
    """
    面向多线程的连接池,提高地图瓦片类高并发场景的响应。
    """
    def __init__(self, minconn, maxconn, *args, **kwargs):
        self._semaphore = Semaphore(maxconn)
        super().__init__(minconn, maxconn, *args, **kwargs)

    def getconn(self, *args, **kwargs):
        self._semaphore.acquire()
        return super().getconn(*args, **kwargs)

    def putconn(self, *args, **kwargs):
        super().putconn(*args, **kwargs)
        self._semaphore.release()


class VectorViewer:
    def __init__(self, connect, table_name, column_name, fid):
        self.table_name = table_name
        self.column_name = column_name
        # 创建一个连接池
        self.connect = ReallyThreadedConnectionPool(5, 10, connect)
        # 约定金字塔表名
        self.pyramid_table = f"{self.table_name}_{self.column_name}"
        self.fid = fid
        self.tileSize = 512
        # self._build_pyramid()

    def _build_pyramid(self):
        """创建金字塔"""
        config = {
            "name": self.pyramid_table,
            "tileSize": self.tileSize
        }
        sql = f"select st_BuildPyramid('{self.table_name}','{self.column_name}','{self.fid}','{json.dumps(config)}')"
        self.poll_query(sql)

    def poll_query(self, query: str):
        pg_connection = self.connect.getconn()
        pg_cursor = pg_connection.cursor()
        pg_cursor.execute(query)
        record = pg_cursor.fetchone()
        pg_connection.commit()
        pg_cursor.close()
        self.connect.putconn(pg_connection)
        if record is not None:
            return record[0]


class PngViewer(VectorViewer):
    def get_png(self, x, y, z):
        # 默认参数
        config = {
            "point_size": 5,
            "line_width": 2,
            "line_color": "#003399FF",
            "fill_color": "#6699CCCC",
            "background": "#FFFFFF00"
        }
        # 在使用psycpg2时,将二进制数据以16进制字符串的形式传回效率更高
        sql = f"select encode(st_aspng('{self.pyramid_table}','{z}_{x}_{y}','{json.dumps(config)}'),'hex')"
        result = self.poll_query(sql)
        # 只有在使用16进制字符串的形式传回时才需要将其转换回来
        result = binascii.a2b_hex(result)
        return result


class MvtViewer(VectorViewer):
    def get_mvt(self, x, y, z):
        # 在使用psycpg2时,将二进制数据以16进制字符串的形式传回效率更高
        sql = f"select encode(st_tile('{self.pyramid_table}','{z}_{x}_{y}'),'hex')"
        result = self.poll_query(sql)
        # 只有在使用16进制字符串的形式传回时才需要将其转换回来
        result = binascii.a2b_hex(result)
        return result


app = Flask(__name__)


@app.route('/vector')
def vector_demo():
    return send_from_directory("./", "Vector.html")

# 定义表名,字段名称等


pngViewer = PngViewer(CONNECTION, 'usbf', 'geom', 'gid')


@app.route('/vector/png/<int:z>/<int:x>/<int:y>')
def vector_png(z, x, y):
    png = pngViewer.get_png(x, y, z)
    return Response(
        response=png,
        mimetype="image/png"
    )


mvtViewer = MvtViewer(CONNECTION, 'points', 'geom', 'gid')

@app.route('/vector/mvt/<int:z>/<int:x>/<int:y>')
def vector_mvt(z, x, y):
    mvt = mvtViewer.get_mvt(x, y, z)
    return Response(
        response=mvt,
        mimetype="application/vnd.mapbox-vector-tile"
    )


if __name__ == "__main__":
    app.run(port=5000, threaded=True)

将上述代码保存为Vector.py文件,并通过执行python Vector.py命令即可启动服务。 从代码中可以推断出,无论使用何种编程语言或框架,只需将矢量或栅格瓦片的SQL语句封装为接口,即可实现完全相同的功能。

相较于发布传统地图服务,利用Ganos的矢量金字塔功能实现在线可视化是一种更为轻量且实用的选择。

  • 针对栅格瓦片,能够通过修改代码实现样式控制,从而显著增强灵活性。

  • 无需引入第三方组件,也无需进行专门的优化,即可实现令人满意的响应性能。

  • 支持自由选择其熟悉的编程语言和框架,无需进行复杂的专业参数配置,对于非地理从业者更加友好。

用户端代码

本案例选择Mapbox作为前端地图框架,以展示后端提供的矢量瓦片层和栅格瓦片层,并为矢量瓦片层配置相应的渲染参数。为便于说明,前端代码同时添加了矢量图层和栅格图层,实际使用时可以根据需要进行选择。在后端代码的同一文件目录下,新建一个名为Vector.html的文件,并写入以下代码。在后端服务启动后,就可以通过http://localhost:5000/vector访问。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <title></title>
    <link
      href="https://cdn.bootcdn.net/ajax/libs/mapbox-gl/1.13.0/mapbox-gl.min.css"
      rel="stylesheet"
    />
  </head>
  <script src="https://cdn.bootcdn.net/ajax/libs/mapbox-gl/1.13.0/mapbox-gl.min.js"></script>
  <script src="https://cdn.bootcdn.net/ajax/libs/axios/0.21.0/axios.min.js"></script>
  <body>
    <div id="map" style="height: 100vh" />
    <script>
      const sources = {
        osm: {
          type: "raster",
          tiles: ["https://b.tile.openstreetmap.org/{z}/{x}/{y}.png"],
          tileSize: 256,
        },
      };
      const layers = [
        {
          id: "base_map",
          type: "raster",
          source: "osm",
          layout: { visibility: "visible" },
        },
      ];
      const map = new mapboxgl.Map({
        container: "map",
        style: { version: 8, layers, sources },
      });
      map.on("load", async () => {
        map.resize();

        // 添加栅格瓦片数据源
        map.addSource("png_source", {
          type: "raster",
          minzoom: 1,
          tiles: [`${window.location.href}/png/{z}/{x}/{y}`],
          tileSize: 512,
        });
        // 添加栅格瓦片图层
        map.addLayer({
          id: "png_layer",
          type: "raster",
          layout: { visibility: "visible" },
          source: "png_source",
        });

        // 添加矢量瓦片数据源
        map.addSource("mvt_source", {
          type: "vector",
          minzoom: 1,
          tiles: [`${window.location.href}/mvt/{z}/{x}/{y}`],
          tileSize: 512,
        });

        // 添加矢量瓦片图层,并为矢量瓦片添加样式
        map.addLayer({
          id: "mvt_layer",
          paint: {
            "circle-radius": 4,
            "circle-color": "#6699CC",
            "circle-stroke-width": 2,
            "circle-opacity": 0.8,
            "circle-stroke-color": "#ffffff",
            "circle-stroke-opacity": 0.9,
          },
          type: "circle",
          source: "mvt_source",
          "source-layer": "points_geom",
        });

      });
    </script>
  </body>
</html>

总结

Ganos的2D矢量快显功能能够有效实现对亿级规模2D矢量数据的可视化,显著解决了传统切片方案在切片时间和存储开销方面的两大痛点。同时,该功能支持局部更新,相较于现有系统,在效率、存储开销及功能丰富性方面均有显著提升。Ganos的3D矢量可视化功能通过对2D矢量切片的扩展,支持Geometry3D数据的可视化,适用于大范围3D场景的可视化需求。