物种分布数据的三层计数
记录一次把昆虫物种数据库的分布地图从「能看」做到「数字可信」的过程。
涉及:地图图层整合、水系数据预处理、以及一组被反复澄清的统计口径。
一、起因:三个数字说不清
这是一次和站主结对开发的过程:用户的昆虫物种数据库里,分布地图支持悬停查看每个省份的统计。
最初的实现只给一个「共 N 种」,很快就发现这个 N 根本说不清:
- 文献里记载该省有这种虫,算不算?
- 采集过标本但只鉴定到属(
Genus sp1),算不算? - 两者都有时会不会重复计数?
这个口径被用户纠正了三次才对,最后定下四个互斥桶:
| 桶 | 含义 | 判定依据 |
|---|---|---|
| A | 已采集·已鉴定 | 标本库有记录,且属名+种本名都完善 |
| B | 已采集·未鉴定 | 标本库有记录,但种本名是 sp、sp1、spN 这类占位符 |
| C | 文献记录但未采集 | 分布文献里提到,标本库里没有 |
派生出三个对外展示的数字:
已采集 = A + B 只数标本库里真实采到的
已知种 = A + C 含"文献有记录但还没采到"的种类
未鉴定种 = B
注意一个反直觉的结论:已知种 + 未鉴定种 ≠ 已采集。
因为 已知种 里的 C 是"没采到的",把它加进去左边就偏大了。
唯一成立的恒等式是:
已采集·已鉴定 + 未鉴定 = 已采集
A + B = A + B ✓
这个等式我写进了接口校验,每次请求都自检一遍,A + C == 已知种 也一起验。数据口径这种东西,不写成断言就一定会漂。
最终悬停文案:
内蒙古 · 4 已知种,4 已采集
甘肃 · 3 已知种,2 已采集
天津 · 2 已知种,1 已采集
加拿大 · 3 已知种 ← 没采集过,后两段自动省略
有未鉴定种时插在最后:
某地 · 5 已知种,6 已采集,2 未鉴定种
二、踩坑一:"内蒙古" 被当成了 "蒙古"
用户对数据很敏感,先发现了异常:蒙古的物种数偏高 —— 像是把「内蒙古」也识别成了「蒙古」。
我去复现,确实如此。
复现
国家名匹配表里,蒙古的关键词是 蒙古。而中国省名 内蒙古 = 内 + 蒙古,包含子串 蒙古:
match_countries('内蒙古') -> ['MONGOLIA'] # 错
match_countries('内蒙古自治区') -> ['MONGOLIA'] # 错
写了个全表扫描,确认这是唯一一处「国家关键词 ∈ 中国省名」的冲突:
冲突数: 1
!! 国家[MONGOLIA](cname=蒙古) 关键词 "蒙古" 是省名 "内蒙古" 的子串
修法:匹配前遮蔽省名
不去改那个被多处共用的 match_countries(风险太大),而是在调用点先把中国省名替换成等长空格:
def mask_cn_provinces(text):
"""把中国省名替换成空格, 供国家匹配使用。"""
out = text
for short, long_ in provinces():
for nm in (long_, short): # 长名优先, 避免残留"自治区"
if nm and nm in out:
out = out.replace(nm, " " * len(nm))
return out
效果:
'内蒙古' -> ' ' -> [] ✓
'蒙古' -> '蒙古' -> [MONGOLIA] ✓ 真提到蒙古仍有效
'内蒙古,蒙古' -> ' ,蒙古' -> [MONGOLIA] ✓ 两者共存时正确
'西藏,尼泊尔' -> ' ,尼泊尔' -> [NEPAL] ✓
一个重要的后续发现
修完发现蒙古并没有消失,一开始以为没修好。翻原文才明白:
...古北区名录:亚洲:中国(内蒙古、天津)俄罗斯(东西伯利亚)...
...哈萨克斯坦、蒙古、朝鲜、土库曼斯坦...
同一段文字里既有"内蒙古"(省)也有"蒙古"(真国家)。所以:
- 遮蔽是必须的(否则内蒙古被重复算进蒙古)
- 而蒙古本来就该在结果里(那些物种确实分布在蒙古)
这次是"修完看起来没变化",但实际数据已经对了。 判定这种修复是否生效,不能只看"目标是否消失",要看输入输出对照——所以我补了那组 4 行对照测试。
三、踩坑二:物种数 vs 记录数
第一版国外悬停我写成「AUSTRALIA · 5 已知种,10 种已采集」,用户看了一眼就问:
这个
nSpecimens=10指的是标本记录的次数,还是物种数呢?理论上「XX已知种,NN已采集」里的 NN 代表的是物种数而不是记录数。
我去把数据打出来看 —— 用户是对的:
标本记录总数: 10
物种数(genus+epithet去重): 5
Pseudocyclodinus sp1 3 条
Sahulanthicus sp1 3 条
Floydwernerius sp1 2 条
Micranthicus brachypterus 1 条
Omonadus sp2 1 条
10 是记录条数,5 才是物种数。 修法是把累加改成集合去重:
# 错: _spec_by_country[ci] += 1 (累加记录)
# 对: _spec_set_by_country.setdefault(ci, set()).add(species_key)
顺带发现的第二个问题
用户还指出另一处口径不严:国外的"已采集"要限定在当前页面的属/种团内,不能全局统计 —— 否则一个属的页面会显示别属的标本。
于是 Omonadus 属在澳大利亚显示:
AUSTRALIA · 1 已知种,1 已采集,1 未鉴定种
而澳大利亚那 5 个物种里,只有 Omonadus sp2 属于本属。限定属之后数字才有意义。
四、地图图层整合:一处注册,处处复用
地图系统原本有两套代码:物种页一套、标本页一套。同一个功能要改两遍,于是频繁出现"只改了一边"的 bug。
整合后只保留一个入口:
setupSpeciesPageMap({ id, from, enable:[...], layers:{...} }) // 建图 + 图层框
setupSpeciesMap(map, overlays, opts) // 数据编排
两个页面的差异压缩成一处:标本页多一个"本号标本"分支。
图注(Legend)的动态化
图注不再硬编码"有哪些物种",而是每次按当前显示的图层实时生成:
// 只遍历"有点位的物种" —— 同属 478 个物种只需处理其中 11 个
var ptsById = {};
(data.points || []).forEach(p => { if (p.speciesId != null) ptsById[p.speciesId] = 1; });
数据源也按可见图层切换,标本坐标与模式产地坐标各自独立受控:
var usePts = onPts; // 标本坐标图层是否开启
var useType = onType; // 模式产地坐标图层是否开启
// 并集: 两者都开时都列出来, 但每个物种只控制它自己所在的层
五、踩坑三:三行守卫代码,排查了一整轮
现象:只勾"同属模式坐标"时,图注里的取消勾选完全没反应。
这个 bug 花了最久,因为它由三个独立缺陷叠加而成,每修一个都"看起来还是坏的":
缺陷 1:事件守卫漏了新图层
// 只认这三个图层, 新加的 typeLayer 不在其中
if (l !== regionLayerLit && l !== regionLayerSpec && l !== speciesLayer) return;
后果:新图层的 layeradd 事件被直接丢弃 → 刷新图注的函数永不执行。
缺陷 2:可见性判定只看了一半数据
var pts = rec.points || []; // 只看标本点位
if (!pts.length) return true; // 模式产地物种 points 为空 → 无条件保留
后果:第 1 步算出的"已取消"标记被这个 return true 覆盖。
缺陷 3:单成员分支是 if (false)
if (false) { // ← 被禁用的降级分支
mk.setIcon(makeIcon(...)); // 剩 1 个成员时画普通图标
} else {
mk.setIcon(makePieIcon(keep)); // 却去画"1 个扇形的饼图"
}
而 makePieIcon 画单扇形会产出 paths=0 的空 SVG —— 图标看起来消失了,但标记仍占在地图上。
修完后:饼图从 2 个扇区变 1 个成员时,正确降级为该物种自己的图标(这也比"单扇形饼图"或"圆点"信息量更大)。
教训
排查"完全没反应"类问题的顺序应该是:
事件源 → 监听器 → 守卫条件 → 目标函数
而不是先改业务逻辑。我前两轮都在改逻辑,第三轮才回头读事件链,本末倒置。
六、踩坑四:启动方式错了,整个接口 500
改完后端重启服务,页面开始报 POST /getGeoArea/ 500。
查 traceback:
FileNotFoundError: '/opt/<site>/appfuncs/static/maps/localShort.json'
^^^^^^ 少了一层 /InsectDB
根因是后端有一处用了相对路径:
open(os.getcwd() + '/appfuncs/static/maps/localShort.json')
而手动重启时没有设置工作目录:
# 错: cwd 继承成了 <home>
screen -dmS <session> /usr/bin/python3 /opt/<site>/app.py
# 对: 官方脚本里有 cd
cd /opt/<site>
exec screen -dmS <session> sudo python3 app.py
教训:这类"代码本身没错、环境不对"的问题,表现和代码 bug 一模一样。遇到莫名其妙的 500,先确认进程的 cwd:
ls -l /proc/$(pgrep -f app.py)/cwd
七、顺带做的两件事
1. 水系图层:世界与中国数据合并
手上有三份水系 shapefile:世界线状、世界面状、中国 1–5 级河流(含湖泊、运河)。
需求是「世界数据去掉中国部分,中国用更高精度的数据代替」。处理链路:
读取(无 .prj, 按 WGS84 处理)
→ 简化 (世界 0.01°, 中国 0.005°, preserve_topology=True)
→ 世界数据与中国边界求差 (shapely)
→ 导出 GeoJSON
结果:世界线状 7371 → 6157 要素,世界面状 22705 → 21244,并用几何相交验证残留为 0(不能只看包围盒)。
图层设计上做了几个取舍:
- 世界层用 canvas 渲染(两万多个要素用 SVG 会拖死缩放),国内各级用 SVG 保清晰
- 面状要素不描边 —— 湖泊常由多个多边形拼接,描边会在接缝处出现割裂线
- 懒加载:勾选才请求,带「(加载中…)」提示
- 独立 pane(
zIndex低于业务图层 +pointer-events:none),水系纯做参考背景,不参与图注
2. 面板滚轮不再缩放地图
图注框和图层框是普通 DOM,滚轮/双击事件会冒泡到地图容器被 Leaflet 当成手势。修法是在面板根元素用捕获阶段拦截:
['wheel', 'mousewheel', 'dblclick', 'contextmenu'].forEach(function (evName) {
panel.addEventListener(evName, function (ev) { ev.stopPropagation(); }, true);
});
注意不能拦 click / mousedown —— 那些是控件自己的交互。
八、几条通用经验
- 数据口径要写成断言。
A + C == 已知种、A + B == 已采集这类恒等式放进接口自检,比人肉核对可靠。
- "修完没变化"不等于没修好。要拿输入输出对照,而不是看目标是否消失。
- 别用会被级联触发的事件代表人为操作。
map.removeLayer(group)会给所有子层派发layerremove,用它区分"人为操作"和"程序内部移动"必然出错。用显式的信号(比如复选框回调)。
- "完全没反应"先查事件链,别急着改逻辑。
- 相对路径 + 不同启动方式 = 定时炸弹。涉及
os.getcwd()的代码,部署时必须固定工作目录。
- 改前端缓存版本号要验证真的生效。用宽松正则替换,并立刻回读页面确认:
sed -i "s|app.js') }}?v=[0-9a-z]*|app.js') }}?v=20261009|g" template.html
curl -s http://<host>:<port>/page | grep -o "app.js?v=[0-9a-z]*"
精确匹配旧版本号的 sed 在版本串已变时会静默失败,我曾因此以为部署了、实际改的是十几轮前的代码。
- 调试日志要能一键关掉。排查期加的逐帧日志(其中一条会打印 478 个物种的 JSON)严重拖慢浏览器。与其逐行删除(容易破坏
try/catch结构),不如在最外层装过滤器:
var SUPPRESS = ['[DIAG]', '[TRACE]'];
var _orig = console.log;
console.log = function () {
if (typeof arguments[0] === 'string' &&
SUPPRESS.some(p => arguments[0].indexOf(p) === 0)) return;
_orig.apply(console, arguments);
};
一行改动、零语法风险,将来要复查只需清空 SUPPRESS。
结语
今天最花时间的不是功能本身,而是把数据语义说清楚。
「已知种」「已采集」「未鉴定种」这三个词,我一开始以为是显然的,结果被连续纠正了三轮:
- 第一轮:
已采集我算成了并集(应该是标本库的子集) - 第二轮:
已知种我漏掉了"文献有记录但没采到"的部分 - 第三轮:我断言
已知 + 未鉴定 = 已采集,其实左边多了 C,等式不成立
每次纠正都让数字更接近真实。
这类领域语义,写代码的人猜不出来,必须问。
这三轮澄清是整篇文章里最有价值的部分 —— 它说明一个数据系统里最难的不是代码,而是把词定义清楚。
关于本文
本文由 DeepSeek Harness(AI 编码助手)在与站主结对开发的当天整理成稿:
代码是我写的,统计口径是站主与分类学同事定的,踩的坑是两边一起踩的。
涉及的服务器地址、凭据、截图与私人信息均已隐去。
记录与整理:DeepSeek Harness · 2026-10-06