简介:一套面向城市规划、GIS分析与建筑研究的芜湖市建筑矢量数据集,以Shapefile标准格式组织,帮助使用者快速获取建筑几何轮廓与空间属性信息,适用于土地管理、城市更新、环境评估等场景。压缩包共5个文件,核心为shp几何数据,配套dbf属性表、shx空间索引、prj坐标系定义及xml元数据说明,整体仅1.71MB,轻量易用,可直接在ArcGIS、QGIS等常见GIS平台中加载。数据包含芜湖市建筑物的边界、高度、形状以及用途、建设年份、建筑面积等属性信息,可开展建筑密度计算、空间分布统计与功能分区识别;结合遥感影像还能进行光照、绿化覆盖率等环境因子分析。已有151人学习下载,适合需要快速获取基础建筑空间数据的高校师生、规划从业者与地理信息爱好者。除支撑专业研究外,开发商可了解地块周边建筑状况,规划部门可评估新建项目环境影响,公众也能便捷获取城市建设信息,为城市更新、遗产保护与公共政策制定提供科学依据。
1. 芜湖建筑数据到底缺什么:先想清楚要回答哪类现实问题
“芜湖建筑数据”听起来像一个文件包,实际接手过这类数据的人都知道,拆开无非是散落的面文件、一张字段残缺的属性表、可能还有一份坐标对不上的CAD总图。真正有价值的形态,是把它整理成能按街道、按年代、按用途批量查询的建筑空间底账。做城市更新评估、区域密度分析或者房屋普查校验的人,缺的就是这张底账。这篇笔记讲的是一条落地路径:把公开POI属性、已有建筑轮廓、人工抽检三股信息对齐清洗,最后落到PostGIS一张表里,让“这个片区建筑密度多少、上世纪90年代前的老房子占多少、沿街用途怎么构成”这类问题能用查询直接回答。
2. 数据从哪里来:POI属性采集与坐标统一的最小闭环
2.1 目标表长什么样:先定字段再找数据,别拿回来再后悔
在下载任何文件之前,我一般先画一张表。芜湖建筑数据的最终形态是“一栋楼一行”的属性表,至少包含这些字段:
id、name、address、geom、source、use_type、floor_count、height、build_year
先定字段不是走形式。name、address、use_type主要靠POI接口和现场调查补;floor_count、height靠建筑普查数据或遥感影像推定;build_year最费劲,需要房屋登记资料和历史影像比对着判断。如果先随便拉几个GeoJSON进来,等发现字段缺了再回补,大部分时间会耗在两张表之间的空间匹配上,后期连“按年代统计”都做不出来。
这一阶段还要把数据格式的预期定死。我要求所有几何统一为Polygon或MultiPolygon,坐标系统一为WGS84经纬度,属性字段名统一小写带下划线,不允许出现中文字段名。原因很简单:后面进PostGIS时,字段名带中文和空格会让SQL写起来极其痛苦;坐标不统一,各种叠图分析就会变成“看起来对了其实全偏”。
2.2 用高德POI补建筑属性:requests抓取脚本与参数设置
POI接口返回的是点,不是建筑面,它不能替代轮廓数据。它真正的价值是给建筑“挂牌照”:名称、地址、分类。拿到这些点之后,再用空间就近匹配挂到你已经有的建筑面上。
import requests import time def fetch_poi(keywords, city="芜湖", page_size=25): url = "https://restapi.amap.com/v3/place/text" items = [] for page in range(1, 101): params = { "key": "你的高德KEY", "city": city, "keywords": keywords, "offset": page_size, "page": page, "extensions": "base", } resp = requests.get(url, params=params, timeout=10) data = resp.json() if data.get("status") != "1" or not data.get("pois"): break items.extend(data["pois"]) total = int(data.get("count", 0)) if page * page_size >= total: break time.sleep(0.3) # 控制QPS,避免触发限流 return items这段代码的逻辑是循环翻页抓取高德文本检索接口,直到把某个关键词下的POI全部拉完。参数里值得留意的有两点:一是city直接传“芜湖”,高德会自动限定区域;二是sleep(0.3)必须有,实测并发请求容易被临时封掉KEY,宁可慢一点也别赌运气。返回的JSON里,每条POI包含location、name、type、address等字段,location是“经度,纬度”字符串,需要拆开转成数值。
我一般会按建筑相关分类去抓,而不是只抓一个大词。比如“商务住宅”“办公”“科教文化服务”“医疗保健服务”“餐饮服务”,每个分类抓一遍,再合并去重。这里有个常见的认知偏差:POI数量分布极不均匀,市区密集、城乡结合部稀疏,抓回来的数据会有明显的空间偏差,后面分析时要注意加权或者单独说明覆盖范围。
2.3 WGS84与GCJ02互转:一道必做的数学题
高德返回的坐标是GCJ02,而很多公开建筑轮廓数据用的是WGS84。直接混用,地图上看起来只是偏了百来米,做面积计算和边界比对时会差出一个身位。坐标转换的代码早就被总结过,但参数容易抄错,这里给一份跑通的版本:
import math def out_of_china(lng, lat): return not (72.004 <= lng <= 137.8347 and 0.8293 <= lat <= 55.8271) def transform_lat(lng, lat): ret = -100.0 + 2.0 * lng + 3.0 * lat + 0.2 * lat * lat + 0.1 * lng * lat ret += 0.2 * math.sqrt(abs(lng)) ret += (20.0 * math.sin(6.0 * lng * math.pi) + 20.0 * math.sin(2.0 * lng * math.pi)) * 2.0 / 3.0 ret += (20.0 * math.sin(lat * math.pi) + 40.0 * math.sin(lat / 3.0 * math.pi)) * 2.0 / 3.0 ret += (160.0 * math.sin(lat / 12.0 * math.pi) + 320.0 * math.sin(lat * math.pi / 30.0)) * 2.0 / 3.0 return ret def transform_lng(lng, lat): ret = 300.0 + lng + 2.0 * lat + 0.1 * lng * lng + 0.1 * lng * lat + 0.1 * math.sqrt(abs(lng)) ret += (20.0 * math.sin(6.0 * lng * math.pi) + 20.0 * math.sin(2.0 * lng * math.pi)) * 2.0 / 3.0 ret += (20.0 * math.sin(lng * math.pi) + 40.0 * math.sin(lng / 3.0 * math.pi)) * 2.0 / 3.0 ret += (150.0 * math.sin(lng / 12.0 * math.pi) + 300.0 * math.sin(lng / 30.0 * math.pi)) * 2.0 / 3.0 return ret def gcj02_to_wgs84(lng, lat): if out_of_china(lng, lat): return lng, lat dlat = transform_lat(lng - 105.0, lat - 35.0) dlng = transform_lng(lng - 105.0, lat - 35.0) radlat = lat / 180.0 * math.pi magic = math.sin(radlat) magic = 1 - 0.006693421622965943 * magic * magic sqrtmagic = math.sqrt(magic) dlat = (dlat * 180.0) / ((6378245.0 * (1 - 0.006693421622965943)) / (magic * sqrtmagic) * math.pi) dlng = (dlng * 180.0) / (6378245.0 / sqrtmagic * math.cos(radlat) * math.pi) return lng - dlng, lat - dlat这套公式里的6378245.0是地球半径,0.006693421622965943是偏心率平方,两个参数错了算出来的偏移会非常离谱。需要注意,这个转换是GCJ02转WGS84,反方向WGS84转GCJ02不要自己推导,网上有另外一套正向公式,逻辑正好相反。如果手头数据的底图本身就是GCJ02,那就不需要做这步转换,强行切到WGS84反而会对不上底图。坐标转换这步是很多翻车的源头,处理前先用几栋地标建筑核对一下经纬度再批量跑。
2.4 拿到文件后的第一轮体检:重复、缺失、范围扫描
POI抓回来、坐标系转完之后,要先做第一轮体检再合并。我用GeoPandas做,速度快、中间能看到每一轮的改动。
import geopandas as gpd gdf = gpd.read_file("wuhu_buildings_raw.geojson") print("原始数量:", len(gdf)) gdf = gdf.drop_duplicates(subset=["geometry"]) gdf = gdf[~gdf.geometry.is_empty] gdf = gdf[gdf.geometry.geom_type.isin(["Polygon", "MultiPolygon"])] subset = gdf.cx[117.9:118.6, 30.8:31.5] # 芜湖主城区粗筛范围 print("清洗后数量:", len(subset))这段代码做四件事:删除完全重复的几何对象、过滤空对象、只保留多边形要素、按坐标范围卡掉明显不在芜湖的区域。cx是GeoPandas的坐标切片,参数是[经度最小值:经度最大值, 纬度最小值:纬度最大值]。芜湖主城区落在东经117.9到118.6、北纬30.8到31.5附近,这个范围可以帮你把误入的周边城市数据挡在外面。注意这里的筛选是在WGS84坐标下做的,如果你还没做坐标转换,这组范围参数需要用GCJ02坐标重新核算。
体检结果如果发现几千个要素只剩一半,先别慌,先看是不是POI点被当成面读进来了,或者轮廓文件本身就只有局部覆盖。这一步能看到数据的真实底子,比入库之后再查错省太多时间。
3. 把散面拼成可查库:几何修复、属性补全与PostGIS入库
3.1 用Shapely修拓扑:自相交、重复点与细小缝隙
从不同来源拼出来的面,肉眼看着没问题,机器一检查全是毛病。最常见的三种:自相交面、闭合点重复、相邻建筑之间的细小缝隙。自相交是内伤,面积计算会因此得出负值或重复值;缝隙不做处理,空间连接的时候会把本属于一栋楼的要素切成几块。
import geopandas as gpd from shapely.validation import make_valid gdf = gpd.read_file("wuhu_clean_step1.geojson") print("修复前无效几何数量:", (~gdf.geometry.is_valid).sum()) gdf["geometry"] = gdf.geometry.apply(make_valid) gdf["geometry"] = gdf.geometry.buffer(0) print("修复后无效几何数量:", (~gdf.geometry.is_valid).sum())make_valid是Shapely里专门修复杂几何的函数,它能把自相交环拆掉、把越界点修正。buffer(0)是处理微自相交和退化面的常用手段,参数传0表示不改变几何大小,只是让Shapely重算一遍边界。跑完之后再统计无效数量,正常情况下应该降到0。这里有一个坑:make_valid处理MultiPolygon时可能把内部空洞变成单独的面,所以修复后要再用一次geom_type检查,确认没有生成奇怪的对象。
如果对修复精度有更高要求,可以进PostGIS后用ST_MakeValid再跑一遍,两边结果基本一致。修复完一定记得重新检查面积,有些面修复后面积变化超过5%,说明原始几何质量问题比较严重,需要回到源数据去看原因。
3.2 属性补全:楼层、高度缺值如何估算
芜湖的建筑属性里,楼层和高度字段通常是不完整的。POI没有层数,公开轮廓数据里也常常缺floor_count。直接删掉这些行会让后续统计产生严重偏差,尤其老旧小区住宅本来就是低层高密度,删了等于变向抬高平均层数。
import numpy as np gdf["floor_count"] = gdf["floor_count"].replace({0: np.nan}) gdf.loc[gdf["floor_count"].isna() & (gdf["use_type"] == "住宅"), "floor_count"] = 6 gdf.loc[gdf["floor_count"].isna() & (gdf["use_type"] == "工业"), "floor_count"] = 2 gdf.loc[gdf["floor_count"].isna() & (gdf["use_type"] == "商业"), "floor_count"] = 8 gdf["height"] = np.where(gdf["floor_count"].isna(), np.nan, gdf["floor_count"] * 3.2)这里的参数是估算值:住宅按6层、工业按2层、商业按8层,层高按3.2米算。整套估算必须留痕,我一般会在表里加一个字段叫estimate_flag,凡是用估算规则补过的行都标记为“估算”,没有补过的标“实测”。不加这个标记,数据交付之后别人会把这些估算值当成真实值用,最后算出问题还是找到你头上。
更讲究一点的做法是按街道分组取中位数,而不是用全城固定值。芜湖老城区和城东新区层数差异很大,用全城统一默认值会低估新区高度、高估老城高度。这部分建议做两次:第一轮用固定值保证数据完整,第二轮按街道分组修正,修正完对比两版总建筑面积,差异超过5%就说明估算逻辑有问题。
3.3 PostGIS入库:让数据能用在项目里
数据清洗到这一步,可以入库了。我统一用PostGIS,理由是在多张表做空间连接时PostGIS的查询速度远超GeoPandas,而且后续增量更新、团队协作都方便。
CREATE TABLE wh_building ( id serial PRIMARY KEY, name text, source text, use_type text, build_year smallint, floor_count smallint, height numeric(6,2), estimate_flag boolean DEFAULT false, geom geometry(Polygon, 4326) ); CREATE INDEX idx_wh_building_geom ON wh_building USING gist(geom);srid=4326是WGS84经纬度的标准编号。为什么不选更高精度的投影坐标系?因为这是一张底账表,后面要做各种跨区划、跨街道的空间连接,用经纬度最稳妥。如果项目要求算平方米面积,查询时临时转投影坐标系再算就行,不影响底表结构。
Python写GeoPandas,配合to_postgis入库:
from sqlalchemy import create_engine engine = create_engine("postgresql://user:password@localhost/gis") gdf.to_postgis( "wh_building", engine, if_exists="append", index=False, schema="public" )if_exists="append"表示追加写入,不要用replace,否则会把之前的数据整表覆盖。入库后跑一次总数确认:
SELECT count(*), round(sum(ST_Area(geom))::numeric, 2) AS total_area FROM wh_building;这里算出来的是球面面积,跟平面投影面积有差异,但用来核对总数量级够用。入库之后,这张表就变成了后续所有分析的地基。
4. 排查:四个让建筑数据翻车的坐标与属性坑
4.1 坑一:两张图叠不上,错位以百米计
现象:把轮廓数据和POI点同时放进GQIS,轮廓在影像上位置正确,POI点却整体偏到隔壁街道;或者轮廓本身和官方底图对不上,平移几十米后勉强重合。
原因:两个数据源坐标系不一致。最常见的是高德POI默认GCJ02,而轮廓数据来自WGS84,混用坐标后经纬度差导致点位偏移。还有一种是数据虽然都标着4326,实际生成时用了GCJ02坐标,只是标签没改。
解决:在入库前用几栋地标建筑做坐标抽检。我一般选芜湖站、鸠兹广场、中山路步行街三个点,分别取它们的WGS84坐标和高德坐标,两套数值做对比,偏差超过0.001度就需要先统一坐标系再入库。入库后用这条SQL查验当前数据的SRID:
SELECT find_srid('public', 'wh_building', 'geom');如果返回的不是4326,先改坐标再继续。切记不要相信文件的属性标签,要以实测为准。
4.2 坑二:楼层数缺失,导致高度估算全部偏小
现象:统计芜湖各街道平均建筑高度,老城区结果异常偏低,很多街道清一色“3层楼”的估算值。
原因:POI不提供层数,轮廓数据里老城区楼栋的floor_count大面积为空,估算规则用了单一默认值,把原本六七层的住宅楼全部算成了低层,而且这些估算值后续被当成了真实值做统计。
解决:估算结果别写进floor_count原始字段,新建一个floor_count_est字段存储估算值,原始字段保留空值。做高度分析时以floor_count_est为准,但交付时单独说明哪些是估算的。更合理的估算方式是按“街道+建筑用途”分组取中位数,芜湖各街道发展不均衡,固定参数只适合第一轮补空,不适合最终交付。
4.3 坑三:建筑年代属性错位,老小区识别失真
现象:用build_year < 1995筛芜湖老旧小区,筛出来的名单和城市更新摸底名单对不上,有的老小区没进结果,有的新建小区反而进了。
原因:建筑年代数据来源混乱。老房子可能在改造后更新了登记年份,把“改造年份”写成了“建成年份”;新项目则出现了“规划许可年份”和“实际竣工年份”混用的情况。
解决:把build_year当成参考字段,不要做单一筛选条件。我在老旧小区识别里通常会把“层数低+砖混结构+年代早”三个条件联合使用,层数低可以用floor_count_est <= 6判断,结构类型需要额外数据,没有就不硬凑。如果条件允许,抽20栋楼对比历史影像,把明显错位的年份标记出来,至少让统计口径可解释。
4.4 坑四:增量更新后出现重叠,数据越修越碎
现象:第二次拉取全量POI和轮廓后,发现同一栋楼在库里出现两个面,一个来自旧数据,一个来自新数据,面积重叠但边界不完全一致,统计时被算成两栋楼。
原因:两次抓取的数据源对建筑边界的描绘本身存在微小差异,直接覆盖更新时旧数据没清干净,新数据又追加进来,自然重叠。更麻烦的是,有些楼是人工修正过的,全量更新会把人工成果冲掉。
解决:入库表里加source_version字段,每次更新写入当前版本号。更新前先用空间判断找重叠:
SELECT a.id, b.id FROM wh_building a JOIN wh_building b ON a.id < b.id AND ST_Intersects(a.geom, b.geom) AND ST_Area(ST_Intersection(a.geom, b.geom)) > ST_Area(a.geom) * 0.5;重叠面积超过小面积对象50%的,直接删掉旧版本或者保留人工修正过的版本。
5. 进阶玩法:用建筑数据算芜湖街道建筑密度并交叉验证
5.1 一条SQL算“街道建筑密度”
建筑底账入库之后,最直接的成果是算街道级的建筑总量。我用边界表和建筑表做空间连接,一条SQL完成:
SELECT b.name AS block_name, round(sum(ST_Area(a.geom))::numeric, 2) AS floor_area_total, round(sum(ST_Area(a.geom))::numeric / NULLIF(ST_Area(b.geom), 0), 4) AS floor_area_ratio, round(count(a.id)::numeric / NULLIF(ST_Area(b.geom), 0), 6) AS building_density FROM wh_block b LEFT JOIN wh_building a ON ST_Intersects(a.geom, b.geom) GROUP BY b.name, b.geom;这段SQL的逻辑是遍历街道边界,找出与之相交的楼栋,汇总楼栋面积然后除以街道面积。参数里值得注意的是NULLIF,它把面积为0的街道转成NULL,避免除零错误;LEFT JOIN保证没有楼栋的街道也能出现在结果中。floor_area_ratio严格来说是楼栋占地密度,不是官方容积率,但用于街道间横向比较已经足够。
运行前先确认建筑表和街道边界表的坐标系一致,否则相交计算会失败或者结果为空。如果两个表坐标系不同,临时用ST_Transform(a.geom, 4326)转换,不要在底表上反复改,保持底表以WGS84为准。
5.2 用统计年鉴做交叉验证
SQL出的结果不能光看。我习惯拿芜湖市公开的统计年鉴做一次总量核验,用年鉴里公布的“房屋面积”或“建筑竣工面积”和库里面的总面积对比。偏差在10%以内说明数据基本可信,偏差超过20%就要警惕是采集遗漏还是坐标系错误。
另一种验证是抽片区实测。随机选20栋楼,在最新影像上人工确认楼层数和轮廓,和库里的floor_count_est比对,错3栋以上就说明估算规则需要调,错5栋以上就直接放弃这版数据,回去检查清洗步骤。
5.3 收尾习惯:一套数据一份版本说明
我个人做完建筑底账,最后一道工序是在QGIS里做一张总图:建筑库叠加影像,按build_year做色带渲染,检查有没有明显空白的城区和颜色突兀的片区,然后连同投影信息、字段说明、估算规则写一份版本说明。这份说明不写进代码,单独存一个markdown文件和数据放一起。前几年我总觉得自己记得住每个字段怎么来的,过了两个月再打开,完全想不起某个估算值的口径是什么,后来把版本说明当成数据交付的一部分,再也没出过这种问题。
数据做完不只是用来算一个数字,要让它能应对“这个片区怎么样”“近几年变化如何”这类追问。日常使用中多看交叉验证,少依赖单次抽取的结果。希望帮到你。
本文还有配套的精品资源,点击获取