第 8 章:地理编码
第 8 章:地理编码
8.1 地理编码概述
地理编码(Geocoding)是将地址文本转换为地理坐标的过程,反向地理编码(Reverse Geocoding)则是将坐标转换为地址。
| 类型 | 输入 | 输出 | 示例 |
|---|---|---|---|
| 正向编码 | 地址文本 | 经纬度 | “北京市朝阳区建国门外大街1号” → (116.4612, 39.9087) |
| 反向编码 | 经纬度 | 地址文本 | (116.4074, 39.9042) → “北京市东城区天安门广场” |
8.2 PostGIS Tiger Geocoder
PostGIS 内置了基于美国 TIGER/Line 数据的地理编码器。
安装配置
-- 安装扩展
CREATE EXTENSION IF NOT EXISTS postgis_tiger_geocoder;
CREATE EXTENSION IF NOT EXISTS fuzzystrmatch;
-- 验证安装
SELECT na.address, na.streetname, na.streettypeabbrev, na.zip
FROM normalize_address('100 Main St, Anytown, OH 44000') AS na;
安装 TIGER 数据
# 下载 TIGER 数据(以加州为例)
# 使用 PostGIS 提供的脚本
psql -d gisdb -c "SELECT tiger.loader_generate_script(ARRAY['CA'], 'gisdata')"
# 这会生成一个 shell 脚本,执行它来下载和导入 TIGER 数据
# 注意:TIGER 数据仅覆盖美国
使用 TIGER Geocoder
-- 正向编码
SELECT g.rating, g.geomout, g.streetname, g.streettypeabbrev, g.zip
FROM geocode('100 Main St, San Francisco, CA 94102') AS g;
-- 反向编码
SELECT pprint_addy(r.addy) AS address, r.street
FROM reverse_geocode(ST_GeomFromText('POINT(-122.4194 37.7749)', 4326)) AS r;
-- 批量编码
WITH addresses AS (
SELECT id, address FROM customers WHERE geom IS NULL
)
INSERT INTO customers (id, address, geom)
SELECT
a.id,
a.address,
g.geomout
FROM addresses a
CROSS JOIN LATERAL geocode(a.address, 1) AS g
WHERE g.rating < 30;
注意: TIGER Geocoder 仅适用于美国地址。中国地址需要其他方案。
8.3 国内地理编码方案
方案对比
| 方案 | 数据源 | 费用 | 精度 | 部署方式 |
|---|---|---|---|---|
| 高德 Web 服务 API | 高德 | 有免费额度 | 高 | 云服务 |
| 百度地图 API | 百度 | 有免费额度 | 高 | 云服务 |
| 腾讯地图 API | 腾讯 | 有免费额度 | 高 | 云服务 |
| Nominatim (OSM) | OpenStreetMap | 免费 | 中 | 自部署 |
| 本地离线数据 | 天地图等 | 视数据许可 | 中-高 | 本地 |
高德地理编码 API 集成
-- 创建扩展以支持 HTTP 请求
CREATE EXTENSION IF NOT EXISTS http;
-- 创建编码结果表
CREATE TABLE geocode_cache (
address TEXT PRIMARY KEY,
lng DOUBLE PRECISION,
lat DOUBLE PRECISION,
formatted_address TEXT,
province TEXT,
city TEXT,
district TEXT,
geocoded_at TIMESTAMP DEFAULT now()
);
CREATE INDEX idx_geocode_cache_addr ON geocode_cache(address);
-- 创建编码函数(使用高德 API)
CREATE OR REPLACE FUNCTION geocode_amap(addr TEXT)
RETURNS TABLE(lng DOUBLE PRECISION, lat DOUBLE PRECISION, formatted TEXT) AS $$
DECLARE
api_key TEXT := 'YOUR_AMAP_API_KEY';
url TEXT;
result JSONB;
BEGIN
-- 检查缓存
RETURN QUERY
SELECT gc.lng, gc.lat, gc.formatted_address
FROM geocode_cache gc
WHERE gc.address = addr;
IF FOUND THEN RETURN; END IF;
-- 调用 API
url := 'https://restapi.amap.com/v3/geocode/geo?address=' ||
urlencode(addr) || '&key=' || api_key;
result := (http_get(url))::jsonb;
IF (result->>'status')::int = 1 AND jsonb_array_length(result->'geocodes') > 0 THEN
DECLARE
geo JSONB := result->'geocodes'->0;
location TEXT := geo->>'location';
pos INTEGER := position(',' IN location);
BEGIN
lng := substring(location, 1, pos - 1)::DOUBLE PRECISION;
lat := substring(location, pos + 1)::DOUBLE PRECISION;
formatted := geo->>'formatted_address';
-- 写入缓存
INSERT INTO geocode_cache (address, lng, lat, formatted_address)
VALUES (addr, lng, lat, formatted)
ON CONFLICT (address) DO NOTHING;
RETURN NEXT;
END;
END IF;
END;
$$ LANGUAGE plpgsql;
-- 使用
SELECT * FROM geocode_amap('北京市朝阳区建国门外大街1号');
注意: 生产环境中应将 API 调用放在应用层而非数据库层,避免数据库负载过高和网络依赖。
8.4 批量地理编码策略
应用层批量编码
# Python 示例:使用 requests 批量编码
import psycopg2
import requests
import time
conn = psycopg2.connect("dbname=gisdb user=postgres")
cur = conn.cursor()
# 获取未编码的地址
cur.execute("SELECT id, address FROM pois WHERE geom IS NULL")
rows = cur.fetchall()
for row_id, address in rows:
# 调用高德 API
resp = requests.get('https://restapi.amap.com/v3/geocode/geo', params={
'address': address,
'key': 'YOUR_API_KEY'
})
data = resp.json()
if data['status'] == '1' and data['geocodes']:
location = data['geocodes'][0]['location']
lng, lat = location.split(',')
cur.execute("""
UPDATE pois
SET geom = ST_SetSRID(ST_MakePoint(%s, %s), 4326)
WHERE id = %s
""", (float(lng), float(lat), row_id))
time.sleep(0.1) # 限速
conn.commit()
conn.close()
分批处理与重试
-- 创建编码任务表
CREATE TABLE geocode_tasks (
id SERIAL PRIMARY KEY,
address TEXT NOT NULL,
status VARCHAR(20) DEFAULT 'pending', -- pending/done/failed
retry_count INTEGER DEFAULT 0,
result_lng DOUBLE PRECISION,
result_lat DOUBLE PRECISION,
error_msg TEXT,
created_at TIMESTAMP DEFAULT now(),
updated_at TIMESTAMP DEFAULT now()
);
-- 查看待编码任务
SELECT count(*) FROM geocode_tasks WHERE status = 'pending';
-- 查看失败任务
SELECT address, error_msg, retry_count
FROM geocode_tasks
WHERE status = 'failed' AND retry_count < 3
ORDER BY created_at;
8.5 反向地理编码
使用 PostGIS 查询地址
-- 反向编码:给定坐标,查询最近的已知地址
CREATE OR REPLACE FUNCTION reverse_geocode_simple(
p_lng DOUBLE PRECISION,
p_lat DOUBLE PRECISION,
p_radius_m INTEGER DEFAULT 100
) RETURNS TABLE(
nearest_address TEXT,
distance_m DOUBLE PRECISION
) AS $$
BEGIN
RETURN QUERY
SELECT
address,
ST_Distance(
geom::geography,
ST_SetSRID(ST_MakePoint(p_lng, p_lat), 4326)::geography
)
FROM pois
WHERE ST_DWithin(
geom::geography,
ST_SetSRID(ST_MakePoint(p_lng, p_lat), 4326)::geography,
p_radius_m
)
ORDER BY geom <-> ST_SetSRID(ST_MakePoint(p_lng, p_lat), 4326)
LIMIT 1;
END;
$$ LANGUAGE plpgsql;
-- 使用
SELECT * FROM reverse_geocode_simple(116.4074, 39.9042);
结合行政区划的反向编码
-- 查询坐标所属的多级行政区划
CREATE OR REPLACE FUNCTION get_admin_hierarchy(
p_lng DOUBLE PRECISION,
p_lat DOUBLE PRECISION
) RETURNS TABLE(
province TEXT,
city TEXT,
district TEXT,
town TEXT
) AS $$
DECLARE
point_geom GEOMETRY;
BEGIN
point_geom := ST_SetSRID(ST_MakePoint(p_lng, p_lat), 4326);
RETURN QUERY
SELECT
p.name::TEXT,
c.name::TEXT,
d.name::TEXT,
t.name::TEXT
FROM districts p
LEFT JOIN districts c ON ST_Contains(c.geom, point_geom) AND c.level = '市'
LEFT JOIN districts d ON ST_Contains(d.geom, point_geom) AND d.level = '区县'
LEFT JOIN districts t ON ST_Contains(t.geom, point_geom) AND t.level = '乡镇'
WHERE ST_Contains(p.geom, point_geom) AND p.level = '省'
LIMIT 1;
END;
$$ LANGUAGE plpgsql;
-- 使用
SELECT * FROM get_admin_hierarchy(116.4074, 39.9042);
-- 输出: 北京市 | 东城区 | 东华门街道
8.6 Nominatim 自部署
Nominatim 是 OpenStreetMap 的开源地理编码引擎。
Docker 部署
# docker-compose.yml
version: '3.8'
services:
nominatim:
image: mediagis/nominatim:4.4
container_name: nominatim
ports:
- "8080:8080"
environment:
NOMINATIM_PASSWORD: "nominatim123"
IMPORT_STYLE: "extratags"
THREADS: 4
volumes:
- nominatim-data:/var/lib/postgresql/16/main
restart: unless-stopped
volumes:
nominatim-data:
# 下载中国数据并导入
# 从 https://download.geofabrik.de/asia/china.html 下载 .osm.pbf 文件
docker exec -it nominatim nominatim import --osm-file /data/china-latest.osm.pbf
# API 调用示例
# 正向编码
curl "http://localhost:8080/search?q=天安门&format=json&limit=1"
# 反向编码
curl "http://localhost:8080/reverse?lat=39.9042&lon=116.4074&format=json"
数据库中查询 Nominatim
-- 如果 Nominatim 和 PostGIS 在同一 PostgreSQL 实例中
-- 可以直接查询 Nominatim 的数据库
SELECT
get_address_by_language(place_id, ARRAY['zh']) AS address,
ST_X(centroid) AS lng,
ST_Y(centroid) AS lat
FROM placex
WHERE name->'name'->>'zh' = '天安门'
LIMIT 5;
8.7 地址标准化
-- 使用 address_standardizer 扩展
CREATE EXTENSION IF NOT EXISTS address_standardizer;
-- 标准化中国地址
-- 注意: 内置规则主要针对英文地址,中文需要自定义规则
-- 创建中文地址分词辅助表
CREATE TABLE address_tokens (
id SERIAL PRIMARY KEY,
raw_address TEXT,
province VARCHAR(50),
city VARCHAR(50),
district VARCHAR(50),
street VARCHAR(200),
house_number VARCHAR(50),
poi_name VARCHAR(200)
);
-- 简单的中文地址解析函数
CREATE OR REPLACE FUNCTION parse_chinese_address(addr TEXT)
RETURNS TABLE(
province TEXT, city TEXT, district TEXT,
street TEXT, house_number TEXT
) AS $$
DECLARE
province_match TEXT;
city_match TEXT;
district_match TEXT;
BEGIN
-- 提取省/市/区
province_match := (regexp_match(addr, '(.{2,4}省|.{2,4}自治区)'))[1];
city_match := (regexp_match(addr, '(.{2,6}市|.{2,6}州)'))[1];
district_match := (regexp_match(addr, '(.{2,6}区|.{2,6}县|.{2,6}市)'))[1];
RETURN QUERY SELECT
COALESCE(province_match, '')::TEXT,
COALESCE(city_match, '')::TEXT,
COALESCE(district_match, '')::TEXT,
''::TEXT,
''::TEXT;
END;
$$ LANGUAGE plpgsql IMMUTABLE;
-- 测试
SELECT * FROM parse_chinese_address('北京市朝阳区建国门外大街1号');
SELECT * FROM parse_chinese_address('广东省深圳市南山区科技园路1号');
8.8 地理编码质量控制
精度验证
-- 验证编码结果:检查是否落在预期的行政区划内
CREATE OR REPLACE FUNCTION validate_geocode(
p_address TEXT,
p_lng DOUBLE PRECISION,
p_lat DOUBLE PRECISION
) RETURNS TABLE(is_valid BOOLEAN, reason TEXT) AS $$
DECLARE
point_geom GEOMETRY;
addr_city TEXT;
BEGIN
point_geom := ST_SetSRID(ST_MakePoint(p_lng, p_lat), 4326);
-- 从地址中提取城市名
addr_city := (regexp_match(p_address, '(.{2,4}市)'))[1];
-- 检查坐标是否落在该城市的行政区内
IF addr_city IS NOT NULL THEN
RETURN QUERY
SELECT
EXISTS(
SELECT 1 FROM districts
WHERE name = addr_city AND ST_Contains(geom, point_geom)
),
CASE
WHEN EXISTS(
SELECT 1 FROM districts
WHERE name = addr_city AND ST_Contains(geom, point_geom)
) THEN '坐标在' || addr_city || '范围内'
ELSE '坐标不在' || addr_city || '范围内,可能偏移'
END;
ELSE
RETURN QUERY SELECT TRUE, '无法验证(地址中未识别城市)'::TEXT;
END IF;
END;
$$ LANGUAGE plpgsql;
-- 批量验证
SELECT address, lng, lat, is_valid, reason
FROM geocode_results
CROSS JOIN LATERAL validate_geocode(address, lng, lat);
重复检测
-- 检测可能的重复编码结果
SELECT
a.address AS addr_a,
b.address AS addr_b,
ST_Distance(a.geom::geography, b.geom::geography) AS distance_m
FROM geocode_results a, geocode_results b
WHERE a.id < b.id
AND ST_DWithin(a.geom::geography, b.geom::geography, 10)
AND a.address != b.address;
8.9 业务场景
场景 1:POI 数据入库
-- 从 CSV 导入 POI 并批量编码
CREATE TEMP TABLE poi_import (
name TEXT,
address TEXT,
category TEXT
);
COPY poi_import FROM '/data/pois.csv' WITH (FORMAT csv, HEADER true);
-- 插入并标记待编码
INSERT INTO pois (name, address, category, geom)
SELECT name, address, category, NULL
FROM poi_import;
-- 后续通过应用层批量编码...
-- 编码完成后更新 geom 字段
场景 2:快递地址解析
-- 快递地址标准化与坐标提取
CREATE TABLE delivery_addresses (
id SERIAL PRIMARY KEY,
raw_address TEXT,
normalized_address TEXT,
geom GEOMETRY(Point, 4326),
confidence NUMERIC(3,2), -- 置信度 0-1
status VARCHAR(20) DEFAULT 'pending'
);
-- 查询高置信度的已编码地址
SELECT raw_address, normalized_address, confidence
FROM delivery_addresses
WHERE status = 'done' AND confidence >= 0.8;
8.10 本章小结
| 要点 | 说明 |
|---|---|
| TIGER Geocoder | 仅适用于美国,PostGIS 内置 |
| 国内方案 | 高德/百度/腾讯 API,或 Nominatim 自部署 |
| 批量编码 | 应用层实现,控制速率和重试 |
| 反向编码 | ST_DWithin + ORDER BY <-> |
| 质量控制 | 行政区划交叉验证、重复检测 |