使用 SQL Server 进行地理编码






4.68/5 (9投票s)
使用 SQL Server 进行地理编码
引言
本文探讨了与地理编码相关的几个问题,特别是
- 将 ESRI Shape 文件转换为 XML
- 解析地址
- 地理编码地址
所有的地理编码都实现为 SQL Server 函数,因此可以提供使用其他方法(如 Web 服务或带有地图软件的 COM 接口(即 MapPoint))无法达到的性能提升。
背景
我第一次尝试地理编码大概是在 3 年前。最初使用一些更广泛的可用服务,如 Google 或 Yahoo Web 服务,确实可以完成任务。但后来,我的实现开始遇到了瓶颈。namely,我有两个限制需要处理:
- 将地址通过公共网络发送在某些我正在做的项目中是不可接受的。
- 我偶尔需要对成千上万条遗留数据进行地理编码。
在网上搜索,我找到了 John Sample 的精彩文章 http://www.johnsample.com/articles/GeocodeWithSqlServer.aspx。他的研究非常有帮助。显然,你所需的一切都在那里,还有大量的免费数据——美国人口普查局的 TIGER/Line 数据,覆盖了美国每个县的每个街区的纬度/经度。一旦有了简单的文本文件,你只需将其加载到数据库中,然后使用插值技术,就可以相对轻松地找到美国每个地址的纬度/经度。
这对我来说已经奏效了几年。然后我开始发现一些问题,我主要将其归咎于 2005-2007 年的住房繁荣 :-)。看,这段时间显然有很多新建房屋,我开始注意到很多新地址在我开始时获得的 TIGER/Line 文件中并不存在。没关系,我想,我只需要获取最新的 TIGER/Line 文件,然后就可以继续了。好吧,猜猜怎么着,美国人口普查局不提供文本格式的 TIGER/Line 文件。现在他们只提供 ESRI 二进制 Shape 文件——这给我的计划带来了麻烦——我真的需要一种方法来获取最新的文本格式地理数据,以便将其加载到我的 SQL Server 中。
本文的其余部分描述了该项目的三个部分
- 将 ESRI Shape 文件转换为 XML
- 将 XML 数据加载到 SQL Server
- SQL Server 函数,用于解析美国地址并进行地理编码
TIGER 文件
美国人口普查局提供拓扑集成地理编码和参照系统——TIGER - 数据。这是每个地区、街区、地标——几乎任何值得关注的事物的县级地理信息。特别是,我对可以按县下载的“所有线路”文件感兴趣(前往 http://www2.census.gov/cgi-bin/shapefiles2009/national-files,导航到你的州,然后是县,下载“所有线路”文件)。在这个文件中,对于每个街区的每个城市街区,你可以找到地址范围、街道名称、邮政编码以及当然的起始和结束街道街区的纬度/经度等信息。最初,此信息以 ASCII 文件下载的形式提供,但自 2006 年以来,此信息仅以 ESRI Shape 文件格式提供。
ESRI Shape 文件 / XML 转换
ESRI 似乎已成为空间数据存储和呈现的实际标准。至少他们的文件和文件支持变得越来越广泛。
也许提供 Shape 文件的高度概览是有价值的。Shape 文件实际上是一组包含至少 4 个文件
- SHP - 包含实际形状的 Shape 文件——一个数据结构列表,主要包含对象类型和每个对象的点列表
- SHX - Shape 索引文件,用于指示文件中每个对象的位置
- DBF - 一个老式的 DBase 数据库文件,其中包含每个 Shape/对象的附加属性。对于 TIGER/Line,这将包含街道名称、邮政编码……但它也可以包含其他通用信息。
- PRJ - 文件信息,例如文件中使用的编码/投影类型。
ESRI 是事实上的领导者这一事实并没有让我的工作轻松多少——将 ESRI Shape 文件转换为文本格式仍然花费了很长时间。最终,我没有尝试寻找一个可以为我完成这项工作的工具,而是开始寻找可以用来完成这项工作的库。
虽然我真的很想找到 C# 代码来完成工作,但最终我选择了 Shapefile C 库 - http://shapelib.maptools.org/,这是最简单且没有依赖项的。他们还有很多关于如何使其工作的示例,这让我的工作相对轻松——打开 DBF 和 SHP 文件,然后为其中的每个对象转储相应的 XML 节点。
对象类型
就地理编码而言,我只对折线感兴趣。并且为了简单起见,我只对直线插值感兴趣,所以即使是折线,我也只对起始点和终点感兴趣。因此,如果你的县区有很多长街区和弯曲的街道,你可能需要考虑让这个过程更精确一些。因此,为了减小文件大小,当 আমি 将对象导出到 XML 文件时,每当遇到折线对象时,我只会导出起始点和终点位置。
对于我参与过的一些其他项目,我需要导出多边形。对于这些,我导出所有点——所以当你查看代码时,不要感到惊讶。
州平面坐标转换
SHP 文件转换的这一部分不用于地址地理编码。但是,对于我参与过的一些其他项目,我不得不处理使用州平面坐标系统编码的 Shape 文件——所谓的兰勃特等角圆锥投影。冒着被地理专家嘲笑的风险,我对这种坐标系统的简单描述是我所理解的——为了提高单独小区域的测绘精度,一种地理编码类型是选择区域的中心,然后提供坐标而不是纬度/经度,而是类似以英尺为单位的向北/向西的坐标——(就像海盗地图——从老树 20 步向北,3 步向东)。听起来很傻,但它似乎相当普遍。至少我不得不处理它,所以我不得不构建一个模块,可以透明地检查 Shape 文件的投影系统并将其即时转换为纬度/经度。
作为转换的基础,我选择了蒙大拿州立图书馆 Montana State Library 发布的代码,并使其更通用,特别是添加了从 Shape 文件的 PRJ 文件中获取投影信息代码。
XML 文件格式
我选择创建的 XML 文件远非通用或可扩展,可能无法完全满足你的所有需求,但它确实为我完成了工作。
<layer>
<shp
property='value' property='value' property='value'
xFrom='lon' yFrom='lat' xTo='lon' yTo='lat'
>
lon lat lon lat ...
</shp>
</layer>
在此文件中,<shp/>
节点为每个 Shape 对象重复。DBF 文件中的每个列/属性都成为 XML 文件中的一个属性(属性=值对)。
如果 Shape 对象是折线(想象一下城市街区),那么会添加 xFrom, yFrom, xTo, yTo
属性来指定折线的起始点和终点。对于任何其他对象类型,我会像 Google KML 文件一样添加经度/纬度点。
Shp2Xml
实用程序将假定所有 Shape 文件组件都在同一个文件夹中,并将输出转储到控制台。因此,要生成 XML 文件,请从命令行运行以下命令
c:\>Shp2Xml SHP_file_name > xml_file_name
文件生成后,你可以使用 grep/findstr 之类的工具来过滤掉你不感兴趣的属性,以减小文件大小,因为这些文件可能会变得非常大——我们说的是数百兆字节。
XML 文件加载
在下载文件中,SQL 文件夹下有一个 LoadTiger.sql 文件,其中包含本文其余部分所述的所有代码。
加载部分非常简单
DECLARE @hDoc int
DECLARE @xml xml
set @xml=(select convert(xml,BulkColumn, 2)
From openrowset(Bulk 'C:\temp\geotrans\edges.xml', single_blob) [rowsetresults])
exec sp_xml_preparedocument @hDoc OUTPUT, @xml
SELECT *
into geoStreet
FROM OPENXML(@hDoc, 'layer/shp', 1)
WITH (
tlid int '@TLID',
prfx varchar(2) ,
street varchar(50) '@FULLNAME',
type varchar(4) ,
LFROMADD varchar(20) '@LFROMADD',
LTOADD varchar(20) '@LTOADD',
RFROMADD varchar(20) '@RFROMADD',
RTOADD varchar(20) '@RTOADD',
fraddr int ,
toaddr int ,
zipl varchar(5) '@ZIPL',
zipr varchar(5) '@ZIPR',
frlon decimal(9,5) '@xFrom',
frlat decimal(9,5) '@yFrom',
tolon decimal(9,5) '@xTo',
tolat decimal(9,5) '@yTo'
)
exec sp_xml_removedocument @hDoc
此段将 c:\temp\geotrans\edges.xml
(根据需要更改)加载到 geoStreet
表中。正如你所见,我只加载了以下列/属性:FULLNAME, LFROMADD, LTOADD, RFROMADD, RTOADD, ZIPL, ZIPR, xFrom, yFrom, xTo, yTo
。
其余的加载代码用于数据清理。
delete from geoStreet
where street is null
or lfromadd is null or ltoadd is null
or rfromadd is null or rtoadd is null
此段从数据集中删除非街区元素——高速公路、上下匝道、河流……。另外,正如你所见,TIGER/Line 数据为街道的左侧/右侧提供了单独的信息。有时左侧和右侧的街道地址编号不如我希望的那样——要么一侧是非数字的,要么有时左侧地址是递增的,而右侧地址是递减的——很奇怪,是吧!为了使地理编码过程更容易,我希望从地址和到地址是可预测的递增顺序。因此,下面的代码会清理这部分。
update geoStreet set lFromAdd=rFromAdd where isnumeric(lFromAdd)=0
update geoStreet set rFromAdd=lFromAdd where isnumeric(rFromAdd)=0
update geoStreet set lToAdd=rToAdd where isnumeric(lToAdd)=0
update geoStreet set rToAdd=lToAdd where isnumeric(rToAdd)=0
update g set g.prfx=a.dir, g.type=a.tp, g.street=a.street
from geoStreet g cross apply fnParseAddr(g.street, 0) a
declare @b varchar(20), @tmpi int, @tmpn numeric(9,5), @tmpt numeric(9,5)
update geoStreet set
@b=LFROMADD, LFROMADD=LTOADD, LTOADD=@b
where convert(int, LFROMADD)>convert(int, LTOADD)
and convert(int, RFROMADD)<convert(int, RTOADD)
update geoStreet set
toaddr=(case when convert(int, RTOADD) > convert(int, LTOADD) _
then RTOADD else LTOADD end),
fraddr=(case when convert(int, RFROMADD) < convert(int, LFROMADD) _
then RFROMADD else LFROMADD end)
where toaddr is null and fraddr is null
update geoStreet set
@tmpi=frAddr, frAddr=toAddr, toAddr=@tmpi,
@tmpn=frlon, frlon=tolon, tolon=@tmpn,
@tmpt=frlat, frlat=tolat, tolat=@tmpt
where frAddr > toAddr
唯一剩下的部分是处理 TIGER/Line 数据中的“所有线路”部分将街道名称作为单个字符串的事实。我决定手动将街道名称解析为方向/名称/类型元素,而不是尝试连接多个文件(稍后会详细介绍解析)。
update g set g.prfx=a.dir, g.type=a.tp, g.street=a.street
from geoStreet g cross apply fnParseAddr(g.street, 0) a
地址解析/地理编码
我发现需要处理的地址格式非常多,让我不知所措。有时我会将所有内容解析为单独的字段——号码、街道公寓等。有时我会获得地址行 1、城市和邮政编码。有时是地址行 1 和地址行 2。此外,有时我需要使用 C# 代码进行地理编码,其中存储过程易于执行,但有时我需要在 SQL Server 脚本中进行,其中存储过程也意味着使用游标以及循环、检查错误代码……的所有复杂性。所以我决定让自己轻松一些:我的地理编码将作为表值函数提供,该函数还将执行地址解析,并且只接受以下格式的单个地址字符串:
StrNum StrDir StrName StrType # apt, City State Zip
其中
StrNum
是数字StrDir
- N、S、E、W、North、South、East、West 之一StrType
- 可能的街道类型之一#
- 分隔可能的公寓号码,
- 第一个逗号将第一个地址行与第二个地址行分开Zip
- 5 位数字可选邮政编码State
- 2 位字符可选州City
- 第二个地址行上的其余所有内容
因此,fnParseAddr
的第一部分执行的正是这个——一次剥离字符串标记,试图解析地址。
查找第一个逗号以将地址拆分为第 1 行和第 2 行。
set @i = charindex(',', @str)
if (@i > 0)
select @city = replace(ltrim(substring(@str, @i+1, 999)), ',', ' '),
@str = replace(rtrim(substring(@str, 1, @i-1)), ',', ' ')
检查第一个标记是否为数字,如果是,则加载街道号码。
set @i = charindex(' ', @str)
if (@i > 0 and isnumeric(substring(@str, 1, @i))=1)
select @num = rtrim(substring(@str, 1, @i-1)),
@str = ltrim(substring(@str, @i+1, 999))
检查第二个标记是否为街道方向(North
/East
/West
/South
)。
set @i = charindex(' ', @str)
if (@i > 0 and substring(@str, 1, @i-1) IN _
('N','No','North','South','S','West','W','East','E'))
select @dir = rtrim(substring(@str, 1, 1)),
@str = ltrim(substring(@str, @i+1, 999))
依此类推。
地址解析后,我们可以尝试确定它属于哪个街区。将所有街区加载到数据库中应该能使这项工作变得微不足道——只需一个简单的 select
,其中街道名称等于指定的内容,并且号码在范围内。但是,街道地址很少是正确的。大多数时候,人们不知道它是一条街还是一个大道。南北东西街道方向很少指定。邮政编码经常是错误的。因此,除了简单的匹配之外,我们还需要一个权重系统——每次匹配都会增加找到正确地址的可能性——如果邮政编码匹配,则得分加 3;如果地址在范围内,则加 2;如果街道类型或方向匹配,则加 1。使用最佳匹配记录。
这转化为以下 SQL 代码:
@wt = (case when @str=street then 5 else 0 end)
+ (case when @zip IN (zipl, zipr) then 3 else 0 end)
+ (case when @num between fraddr and toaddr then 2 else 0 end)
+ (case when @dir=prfx then 1 else 0 end)
+ (case when @tp=type then 1 else 0 end)
现在已经找到了最匹配我们地址的记录,我们需要找到实际的纬度/经度。为此,我们使用插值:如果一个街区包含地址从 100 到 200,而我们正在寻找房子号 125,那么它应该在街区开始处的大约四分之一处。
select @ratio = 0.5
if @num between @fraddr and @toaddr or @num between @toaddr and @fraddr
select @ratio = 1.0 *(@num - @fraddr) / (@toaddr - @fraddr)
select @lat = (@ratio * (@tolat - @frlat) + @frlat) ,
@lon = (@ratio * (@tolon - @frlon) + @frlon)
Using the Code
运行 LoadTiger.sql 脚本后,它将创建 geoStreet
表和 fnParseAddr
函数。该表由函数用于地理编码地址。该函数接受两个参数:
Address
- 有关接受的地址格式,请参阅上一节。ifGeocode
- 0/1 标志,用于指定是只需要地址解析还是需要执行完整的地理编码。
该函数返回一个仅包含 1 行的表:
num |
varchar(20) | 街道号码 |
dir |
varchar(1) | 街道方向 |
street |
varchar(100) | 街道名称 |
tp |
varchar(20) | 街道类型 |
apt |
varchar(20) | 公寓 |
city |
varchar(100) | city |
st |
varchar(2) | 状态 |
zip |
varchar(5) | zip |
lat |
decimal(9,5) | 纬度 |
lon |
decimal(9,5) | 经度 |
要仅解析地址,请使用以下 SQL 命令:
select * from fnParseAddr('123 main st', 0)
要解析和地理编码地址,请发出以下 select
语句:
select * from fnParseAddr('123 main st', 1)
要对数据库中的某个表执行批量地理编码,请发出以下命令:
select tbl.stname, a.*
from SomeTable tbl
cross apply fnParseAddr(tbl.stname, 1) a
或者,类似地更新信息:
update tbl set tbl.lat=a.lat, tbl.lon=a.lon
from SomeTable tbl
cross apply fnParseAddr(tbl.stname, 1) a
where a.lat != 0
注释
项目的某些部分可能受益于改进。以下是我希望在某个时候解决的问题列表:
- 更灵活的地址解析。目前,对于像 123 Avenue 52 E 这样的地址,我会遇到问题——它不太符合常规地址格式。另外,解析公寓号码(目前用 # 分隔)还有改进的空间。
- 支持除纬度/经度或州平面以外的坐标投影系统。
- 性能改进——虽然我在双核 3GHz Xeon 和 3GB RAM 上获得了令人满意的 1,700 条记录/秒的性能,但通过创建托管代码来解析地址,性能可能会进一步提高。