简介:全国火车站站点位置GIS数据包面向地理信息、交通研究与空间分析学习者,以及需要火车站POI底图进行地图制图或二次开发的从业者,提供可被QGIS、ArcGIS等软件直接读取的全国火车站点位数据,用于站点分布查看、区域筛选、路径计算和铁路线路规划等场景。压缩包共8个文件,约405KB,类型覆盖shp矢量主文件、shx与sbn/sbx空间索引、prj投影参考、dbf属性表、cpg编码说明及xml元数据,既记录火车站坐标,也承载名称、所属线路、服务类型、城市等属性信息,文件配套完整,便于快速加载与查询。已有396人学习。读者可获得一套轻量但结构规范的GIS数据集,用于地图可视化、属性检索、空间统计分析及与企业IT系统集成,也适合作为教学演示和二次开发的基础底图,减少自建站点数据的时间成本。
1. 全国火车站的站点位置GIS:一张点表背后有多少脏活
某开发者接了个需求,给用户算“离我最近的火车站”,原本以为找一张全国火车站的经纬度表就完事。三天后他卡在三件事上:同一个城市查出四个同名站,两个数据源的点位差了五百米,某个大型客站的点落在铁轨中间的水塘里。全国火车站的站点位置 GIS,本质上就是把每一座客运站抽象成一个带经纬度、带规范站名、带行政归属和等级属性的点要素,统一坐标系后灌进空间数据库,支撑最近站查询、线路串联、客流热力分析这类空间计算。适合做出行规划、物流选址、地图可视化的开发者,也适合拿车站当空间锚点做统计的分析人员。不适合只想复制一张静态表格贴进报告的人——那张表看着干净,背后的坐标纠偏、同名合并、属性校准才是真正决定能不能用的部分。
2. 火车站POI从哪来:三类数据源取舍与字段设计
2.1 名录、POI、公开数据集各自能给你什么
做全国火车站的点位,绕不开三类来源,而它们的短板几乎互补。
第一类是行业名录性质的站点清单,站名规范、等级明确、所属线路清楚,缺点是几乎没有可用坐标,即便有也常常只精确到城市级,直接落到地图上能偏出几十公里。第二类是地图开放平台的 POI 检索接口,坐标精度够、覆盖全、新开通的站也能查到,但分类码里混着铁路货场、售票代售点、公交枢纽,甚至小区名字里带“站”字的也会被召回,而且返回的是加密坐标系。第三类是公开地理数据集,坐标直接可用,缺点是更新滞后,近两年新投用的车站经常缺失,站名写法也不统一。
实际做法是让三者互相校准:以 POI 提供坐标骨架,以名录提供站名与等级属性,以公开数据集做兜底和交叉核对。三者对不上时,优先信名录的站名、信 POI 的坐标,但要把差异记录下来人工抽查。
| 数据源 | 坐标精度 | 属性完整度 | 更新速度 | 主要风险 |
|---|---|---|---|---|
| 行业名录 | 城市级或无 | 高 | 中 | 无法直接上图 |
| 地图POI接口 | 米级 | 中 | 快 | 混入非客运设施、坐标加密 |
| 公开地理数据集 | 米级 | 低 | 慢 | 新站缺失、站名不统一 |
选型的判断标准只有一条:这张点表要拿来做什么空间计算。如果只做“某城市有没有站”,城市级坐标够用;如果做最近站排序、步行接驳距离、站点覆盖半径分析,米级精度是硬门槛。
2.2 一个站点一行,字段表怎么设计
字段设计决定了后面能不能去重、能不能增量更新、能不能追溯来源。我一般会保留下面这组字段,宁可多留几列,也不要等到出问题再回头补。
| 字段 | 类型 | 说明 |
|---|---|---|
| station_name | text | 规范化站名,去掉“站”字后统一存储 |
| alias | text[] | 别名数组,兼容“XX东”和“XX东站”两种写法 |
| city_code | text | 行政区划编码,用于按城市聚合和兜底排序 |
| station_grade | text | 等级,特等/一等/二等/越行站等 |
| station_type | text | 客运/货运/编组/乘降所,过滤时最关键的一列 |
| lng / lat | numeric | 原始坐标,保留来源精度 |
| src_srid | int | 原始坐标系编号,转换前必存 |
| source / source_id | text | 来源标识与来源内唯一 ID,做幂等更新用 |
| updated_at | timestamptz | 用于判断是否需要重新拉取 |
source_id这一列很多人会省掉,等到第二次跑抓取脚本时就会后悔——没有它,你没法判断这条记录是新增还是已经存在,只能整表清空重灌,历史修正记录全丢。src_srid同理,混着三套坐标系的数据进了库,事后没人说得清哪一行的坐标原本是什么基准。
station_type是最容易被忽视的一列。只按关键词搜“火车站”,召回结果里货运站和乘降所占比不低,如果下游在做“旅客最近乘车站”的计算,这些点必须提前过滤掉。
2.3 用 Python 跑一次批量检索与落盘
下面这段是抓取骨架,去掉厂商相关细节,保留分页、限速、失败重试和断点续跑四个必要机制。示例里的站名和城市全部是虚构代称。
import csv, time, json, random import requests ENDPOINT = "https://example-map-api.invalid/place/text" KEY = "YOUR_KEY" CITIES = ["示例市甲", "示例市乙"] # 按行政区划遍历,避免全国一次性拉取 KEYWORDS = ["火车站", "高铁站", "客运站"] OUT = "stations_raw.csv" SEEN = set() # 断点续跑:已处理的 source_id def fetch(keyword, city, page): params = { "key": KEY, "keywords": keyword, "city": city, "page": page, "offset": 20, "extensions": "all", } # 三次指数退避,避免偶发超时把整批任务打断 for attempt in range(3): try: r = requests.get(ENDPOINT, params=params, timeout=8) r.raise_for_status() return r.json().get("pois") or [] except Exception: time.sleep(2 ** attempt + random.random()) return [] with open(OUT, "a", newline="", encoding="utf-8") as f: w = csv.writer(f) for city in CITIES: for kw in KEYWORDS: for page in range(1, 6): # 单关键词单城市一般不超过 5 页 pois = fetch(kw, city, page) if not pois: break for p in pois: sid = f"{p.get('id')}" if sid in SEEN: continue SEEN.add(sid) w.writerow([ p.get("name"), city, p.get("type"), p.get("location", "").split(",")[0], p.get("location", "").split(",")[1], sid, kw, ]) time.sleep(0.35) # 限速是硬要求,不是优化项代码逻辑上分三层:外层按城市和关键词组合遍历,中层翻页,内层按source_id去重后落盘。offset=20要和接口允许的单页上限对齐,改大了会被截断,改小了请求数翻倍。time.sleep(0.35)看着保守,但全国几百个城市跑下来,这个值直接决定你会不会被限流踢出。
SEEN集合只存在内存里,进程重启就丢。稳妥一点的做法是把已处理的source_id落到一张单独的表或一个本地 sqlite 里,每次启动先加载。这个是血泪经验,一次跑到八成中断,重跑又从头开始,白等两小时。
落盘后先别急着入库,第一步是分类清洗:把type里明显属于货运、编组、代售点的记录打标,把名称里不含车站后缀却命中的记录单独挑出来人工过一遍。清洗完的量和原始召回量通常差三成以上,这一步不做,后面所有的空间查询结果都会脏。
3. 坐标系对齐:三套基准混用为什么会错位几百米
3.1 WGS84、GCJ-02、BD-09 的差别与选型
国内能拿到的坐标基本绕不开三套基准。WGS84 是国际通用的地心坐标系,GPS 原始输出、多数公开地理数据集都是这一套。GCJ-02 是在 WGS84 基础上做非线性偏移的加密坐标系,国内主流地图底图大多用它,偏移量在同一城市内相对稳定,但不同位置不一致。BD-09 又是在 GCJ-02 上再叠一层偏移,只服务于特定底图。
错位的根源不是精度不够,而是基准不同。把 WGS84 的点直接叠在 GCJ-02 的底图上,同一座车站在视觉上会平移几百米,方向在城市内部大致一致,跨城市就不一样了。更麻烦的是这个误差不会报错,只会让你觉得“数据挺对的”,直到有人拿着导航走过去发现差了半条街。
选型思路很直接:存储层统一用 WGS84,因为它是可逆、可公开计算、跨工具通用的基准;只在出图或对接特定底图时临时转成目标坐标系,转完不落库。这样无论后面换什么底图,源数据都不用重做。
3.2 批量转换的代码与参数含义
下面是常用的正向转换实现,只保留必要参数。函数入口先判断坐标是否落在转换适用范围内,范围外直接原样返回,避免把境外站点也强行偏移。
import math A = 6378245.0 # 克拉索夫斯基椭球长半轴 EE = 0.00669342162296594323 # 第一偏心率平方 X_PI = math.pi * 3000.0 / 180.0 def in_scope(lng, lat): # 转换算法只在特定经纬度范围内有效,范围外不做偏移 return 73.66 < lng < 135.05 and 3.86 < lat < 53.55 def _tlat(x, y): r = -100.0 + 2.0*x + 3.0*y + 0.2*y*y + 0.1*x*y + 0.2*math.sqrt(abs(x)) r += (20.0*math.sin(6.0*x*math.pi) + 20.0*math.sin(2.0*x*math.pi)) * 2.0/3.0 r += (20.0*math.sin(y*math.pi) + 40.0*math.sin(y/3.0*math.pi)) * 2.0/3.0 r += (160.0*math.sin(y/12.0*math.pi) + 320*math.sin(y*math.pi/30.0)) * 2.0/3.0 return r def _tlng(x, y): r = 300.0 + x + 2.0*y + 0.1*x*x + 0.1*x*y + 0.1*math.sqrt(abs(x)) r += (20.0*math.sin(6.0*x*math.pi) + 20.0*math.sin(2.0*x*math.pi)) * 2.0/3.0 r += (20.0*math.sin(x*math.pi) + 40.0*math.sin(x/3.0*math.pi)) * 2.0/3.0 r += (150.0*math.sin(x/12.0*math.pi) + 300.0*math.sin(x/30.0*math.pi)) * 2.0/3.0 return r def wgs84_to_gcj02(lng, lat): if not in_scope(lng, lat): return lng, lat dlat = _tlat(lng - 105.0, lat - 35.0) dlng = _tlng(lng - 105.0, lat - 35.0) rad = lat / 180.0 * math.pi magic = 1 - EE * math.sin(rad) ** 2 sqrtmagic = math.sqrt(magic) dlat = (dlat * 180.0) / ((A * (1 - EE)) / (magic * sqrtmagic) * math.pi) dlng = (dlng * 180.0) / (A / sqrtmagic * math.cos(rad) * math.pi) return lng + dlng, lat + dlat def gcj02_to_bd09(lng, lat): z = math.sqrt(lng*lng + lat*lat) + 0.00002 * math.sin(lat * X_PI) theta = math.atan2(lat, lng) + 0.000003 * math.cos(lng * X_PI) return z * math.cos(theta) + 0.0065, z * math.sin(theta) + 0.006A和EE不能随手改成 WGS84 的椭球参数,这套偏移量是跟特定椭球绑定算出来的,换了参数结果整体漂移。in_scope的边界值来自算法本身的适用范围,不是业务判断,删掉会导致境外或极区坐标被无意义地偏移几十公里。
反向转换(GCJ-02 到 WGS84)没有精确解析解,常见做法是用一次正向转换后按差值迭代逼近,一般迭代两到三次就能把残差压到米级以内。这类“不完美但够用”的处理在坐标系转换里很常见,别指望有一步到位的公式。
3.3 用同名站对做交叉验证,把残差量化出来
转换完必须验证,靠肉眼看地图不算验证。做法是从两个来源各取一批同名站,配对后算距离,看残差的分布是不是收敛。
from math import radians, sin, cos, asin, sqrt def haversine(lng1, lat1, lng2, lat2): lng1, lat1, lng2, lat2 = map(radians, [lng1, lat1, lng2, lat2]) d = sin((lat1-lat2)/2)**2 + cos(lat1)*cos(lat2)*sin((lng1-lng2)/2)**2 return 2 * 6371000 * asin(sqrt(d)) # 返回米 # pairs: [(站名, 源A经度, 源A纬度, 源B经度, 源B纬度), ...] residuals = [(name, haversine(a1, b1, a2, b2)) for name, a1, b1, a2, b2 in pairs] residuals.sort(key=lambda x: x[1], reverse=True) for name, d in residuals[:20]: # 只看差异最大的 20 条 print(f"{name}\t{d:.1f} m")判断标准按业务定:同名站在两源之间距离普遍在 50 米以内,说明坐标系正确、点位可接受;大量落在 300 到 800 米区间,基本可以判定是基准没统一;少数几条极大值通常是配对错了对象,比如把“示例站甲东”和“示例站甲”配成了一对。
这个脚本跑一遍,往往能顺手发现一批数据质量问题:站名相同但分属两座城市的、同一座站被标在站房和站台两个位置的、坐标落进水域的。修完再入库,比入库后回头捞干净得多。
4. 灌进PostGIS:建表、去重合并与最近站查询
4.1 建表与SRID选择
空间库的建表不是把字段堆上去就完事,几何列的类型和 SRID 一旦定错,后面所有查询都受影响。
CREATE TABLE rail_station ( id bigserial PRIMARY KEY, station_name text NOT NULL, alias text[], city_code text, station_grade text, station_type text, lng numeric(10,6), lat numeric(10,6), src_srid int, source text, source_id text, geom geometry(Point, 4326) NOT NULL, updated_at timestamptz DEFAULT now() ); -- 来源内唯一,保证重复抓取不会灌出重复行 CREATE UNIQUE INDEX uq_rail_station_source ON rail_station (source, source_id); -- 空间索引,所有距离查询的前提 CREATE INDEX idx_rail_station_geom ON rail_station USING GIST (geom);几何列固定写geometry(Point, 4326),不要用无类型的geometry。带上类型约束后,插入非点类型的数据会直接报错,带上 SRID 后,跨 SRID 的隐式运算会被拦住,这两个约束能在数据入口就挡掉一批脏数据。
lng/lat独立保留一份,是为了排查问题时不依赖空间函数。有几次坐标明显异常,最后定位到的原因是插入时经纬度顺序反了,几何列看着“有点偏”,只有把原始数值拉出来才看得清。
4.2 同名站、多站房、多站台的合并策略
一座大型客站,在 POI 里经常出现三到五条:站房、站台、东广场、西广场、地下停车场。不合并的话,“某市有多少个火车站”这类统计直接崩。
合并的判断条件是“同名 + 距离阈值内”。阈值取 1200 米比较稳,能覆盖大多数枢纽站的站房到站台距离,又不至于把同城的两个不同车站误并。
WITH pairs AS ( SELECT a.id AS a_id, b.id AS b_id, a.station_name FROM rail_station a JOIN rail_station b ON a.id < b.id AND a.station_name = b.station_name AND ST_DWithin(a.geom::geography, b.geom::geography, 1200) ) SELECT station_name, count(*) AS dup_groups FROM pairs GROUP BY 1 ORDER BY 2 DESC;先把重复组查出来人工看一遍,别直接开删。实际跑下来会看到两类情况:一类确实是一座站的多个点位,合并成质心即可;另一类是同名但分属不同线路的两座站,比如老站和新站共用一个名字,这种必须保留两条,靠city_code加线路属性区分。
合并动作用一个物化视图或新表承接,别在原表上原地改。
CREATE TABLE rail_station_merged AS SELECT min(id) AS id, station_name, min(city_code) AS city_code, min(station_type) AS station_type, ST_Centroid(ST_Collect(geom))::geometry(Point, 4326) AS geom, count(*) AS src_cnt FROM rail_station WHERE station_type = '客运' GROUP BY station_name, city_code;ST_Collect收集同名站的所有点位,ST_Centroid取质心。质心法在两点分布时是折中位置,可能落在两个点中间的空白区域,对“最近站距离”的计算影响可控,但如果要做“步行可达性”分析就不合适,那种场景应该保留实际站房点位,把站台点作为附属信息挂上去。
4.3 最近站点查询:ST_DWithin 与 KNN 的写法差异
最常用的查询是“给定坐标,找最近的几个站”。写法上有两个坑:单位,和索引命中。
直接对geometry(Point, 4326)用ST_DWithin时,距离参数的单位是度,不是米。写ST_DWithin(geom, pt, 50000)本意是 50 公里,实际含义是 50 度,几乎覆盖全国。正确写法是转geography:
-- 先粗筛,再精算,两级过滤让索引能发挥作用 WITH cand AS ( SELECT id, station_name, geom FROM rail_station_merged WHERE ST_DWithin( geom::geography, ST_SetSRID(ST_MakePoint(:lng, :lat), 4326)::geography, 50000 -- 单位:米 ) ) SELECT id, station_name, ST_Distance(geom::geography, ST_SetSRID(ST_MakePoint(:lng, :lat), 4326)::geography) AS dist_m FROM cand ORDER BY dist_m LIMIT 5;如果不设半径,只要最近的 N 个,用 KNN 更合适:
SELECT id, station_name, ST_Distance(geom::geography, ST_SetSRID(ST_MakePoint(:lng, :lat), 4326)::geography) AS dist_m FROM rail_station_merged ORDER BY geom <-> ST_SetSRID(ST_MakePoint(:lng, :lat), 4326) LIMIT 5;<->走的是 GIST 索引的 KNN 排序,不需要全表算距离,数据量大时差距非常明显。但要注意它按几何列的原生单位排序,在 4326 下是度,不是米。北纬四十度附近,经度一度的实际长度只有纬度一度的大约四分之三,纯按度数排序会让你在东西向和南北向得到不一致的次序。
稳妥做法是 KNN 先取一批候选(比如 20 条),再用ST_Distance按米精排取前 5。多一次排序,换掉一类很难复现的边界错误。
数据量到了一定量级后,建议把几何列另存一份米制投影(如按所在分区选择的投影坐标系),距离计算直接在投影坐标上做,省掉每次的geography转换开销。这个优化的收益在百万级点位时才明显,几十万以内geography完全够用。
5. 避坑与排查:火车站点位最常见的五个翻车现场
5.1 名称与坐标类问题
现象一:某城市统计出四十多个“东站”。原因是关键词检索的召回过宽,“XX东站”既可能是客运站,也可能是公交首末站、铁路货场,甚至是小区名。解决分三层:先在抓取阶段按分类码过滤掉非铁路设施,再在名称上用正则约束后缀,最后按station_type字段人工过一遍边界样本。三层都做完,误召回能压到个位数。
现象二:整批点位向同一方向偏移几百米,看着很整齐。这是最典型的坐标系混用,数据源给的是 WGS84,底图是 GCJ-02,直接叠加就整体平移。解决是入库前统一转到 WGS84,并在src_srid里记下原始基准。判断方法很简单:如果偏移方向在城市内部一致、跨城市不一致,基本可以锁定是基准问题,不是数据本身错了。
现象三:同一座车站在库里三条记录,彼此相距两百到八百米。站房、站台、广场各被标了一次。解决是用station_name加 1200 米阈值做聚合,但要先人工确认这些点属于同一座站,而不是老站和新站同名。这一步偷懒,后面所有“站点数量”的口径都对不上。
现象四:个别点位落进水域或者山里。原因是 POI 定位到了行政区中心或随机撒点,不是实际站房。解决是靠人工抽查加规则兜底:把落在大型水体面内的点标出来复核,实在定不准的,用同一城市同名站的质心替代,并在属性里标记“坐标置信度低”。
5.2 入库与查询类问题
现象五:最近站查询在省界附近返回隔壁省的站,或者半径内明明有站却查不到。前者是业务规则问题,需要在查询里加city_code或行政边界的软约束;后者基本都是单位问题——geometry上的ST_DWithin用度做单位,写 5000 以为是五公里,实际是五千度。改成::geography后单位立刻变成米,这个错误在自查时很难发现,因为查询不报错,只是结果不对。
现象六:批量更新后查询从二十毫秒变成两秒。大批量写入会打乱统计信息,查询计划器选不到索引。解决是写完立刻ANALYZE rail_station_merged;,并用EXPLAIN (ANALYZE, BUFFERS)确认走的是 GIST 索引而不是顺序扫描。这个动作我一般写进入库脚本的最后一步,不靠人记。
提示:跨省界、跨水域、跨山体这几类边界场景,建议单独准备一批测试坐标做回归。这类用例平时跑不到,上线后第一个投诉的往往就是它们。
6. 上线前我会跑的三步验证
点表灌完、接口写完,别急着接前端。我自己固定跑三步,每步都能捞出一批真实问题。
第一步是抽样人工核对。按等级分层抽样,特等站和一等站全查,二等及以下随机抽五个百分点,逐条拿站名和坐标去地图上比对。重点不是看坐标准不准,而是看这座站到底存不存在、是不是客运站。抽一百条能捞出三到五条问题,这个比例在初期很正常。
第二步是边界用例回归。准备一组刻意刁钻的坐标:省界正中、大型水域中央、山区无人区、两座相邻大站的正中间。前两类验证的是单位与行政约束,后两类验证的是排序稳定性——两站距离相差不到五十米时,结果会不会来回跳。这类用例写成固定脚本,每次改查询逻辑都跑一遍。
CASES = [ ("省界中点", 116.0000, 36.0000, {"must_have_result": True}), ("水域中心", 118.5000, 31.2000, {"max_dist_km": 200}), ("山区无站区", 100.2000, 30.5000, {"max_dist_km": 300}), ("双站中点", 117.2000, 34.2000, {"top2_gap_m_max": 500}), ] for name, lng, lat, expect in CASES: rows = query_nearest(lng, lat, limit=5) assert rows, f"{name}: 无结果" if "max_dist_km" in expect: assert rows[0]["dist_m"] <= expect["max_dist_km"] * 1000, f"{name}: 最近站过远" if "top2_gap_m_max" in expect: gap = rows[1]["dist_m"] - rows[0]["dist_m"] assert gap <= expect["top2_gap_m_max"], f"{name}: 前两名距离过近,排序不稳"第三步是慢查询回归。把接口层最近一周的真实查询坐标落成样本,离线跑一遍,记录 P99 耗时和索引命中情况。加了新索引、改了合并策略之后重跑,对比是否退化。
最后一个技巧:给接口加城市兜底。当半径内没有查到任何站时,不要直接返回空,而是按city_code取该城市等级最高的三座站返回,并在响应里标记fallback=true。这一条能把“查询无结果”的投诉压掉大半——用户真正想知道的往往不是精确距离,而是“我该去哪个站”。
做这类点位数据,我最大的教训是别信任第一次跑出来的结果。坐标看着对、数量看着合理,往往只是还没碰到边界。每加一层清洗规则就回头重跑一次统计,把数量变化记下来,哪天数字突然跳变,你会知道是数据源变了还是自己的规则写错了。希望帮到你。
本文还有配套的精品资源,点击获取