简介:压缩包面向生态学、景观连通性与保护规划方向的研究者、规划师及学生,围绕电路理论模拟物种迁移和栖息地连通性,解决生态廊道识别、瓶颈区定位与障碍阻隔评估等问题。包内共264个文件、约95.49MB,含Python脚本、地理数据库表与索引、ADF栅格、MXD工程、PDF说明及SHP矢量数据,分别用于批量处理、空间数据组织、制图展示和结果核查。已有2612人学习下载,具备较高参考价值。覆盖Linkage Mapper的廊道识别与连通性制图、Pinchpoint Mapper的关键“掐点”保护分析、Barriers Mapper的障碍量化,并涉及物种分布、地形高程到栅格/矢量输入的完整流程,帮助读者理解电路理论如何转化为连通性矩阵和地图,为保护区布局、生态走廊规划及障碍修复提供可复用的工具与案例参照。
1. 从一张阻力面到一张电流密度图:这套包解决的是“多条路都算”的问题
做生态廊道的人常被一个问题噎住:最小成本路径给你一条线,评审会上有人问“为什么不是旁边那条”,你很难答。基于电路理论的 Circuitscape 换了个问法——把景观当成电阻面,把栖息地斑块当成电极,通上电,看电流在哪儿密。电流密度高的地方,就是个体移动概率高的地方,而且是所有路径同时解出来的,不是一条最优线。这套Circuitscape软件包集.zip收的正是这么一套东西:计算引擎、Python 接口、图形界面,外加一个做全景连通性的 Omniscape。它适合两类人:手上已经有阻力面、想把廊道结论从“一条线”升级成“一张概率图”的从业者;以及要批量跑几十组情景、必须靠脚本而不是点鼠标的人。
2. 解包后先认清四件套:引擎、接口、界面和 Omniscape 各管什么
压缩包解开之后不要急着双击。这套东西不是一个大程序,而是四个定位完全不同的部件拼在一起的。搞混了就会出现“我在界面上调好的参数,脚本里跑出来完全不一样”这种事——不是软件有 bug,是你用错了入口。下面按“谁在什么场景下该用哪个”拆开讲,再落到三份输入数据的合格标准,最后跑通一个最小算例。
2.1 四个部件的分工与选择
| 部件 | 形态 | 什么场景用 | 依赖与注意 |
|---|---|---|---|
| 计算引擎 | 命令行入口,可用python -m circuitscape 配置.ini调起 | 服务器批量跑、接调度系统、复现实验 | 需要 Python 环境和包内依赖,路径别带空格和中文 |
| Python 接口 | circuitscape模块 | 在自己的脚本里循环、读结果、做统计 | 与引擎同源,参数完全一致 |
| 图形界面 | 桌面程序 | 第一次摸参数、检查输入对不对、单次出图 | 生成的 ini 是极好的模板,直接抄 |
| Omniscape | 独立程序 | 区域尺度、不分源汇的全景连通 | 运行时独立,配置格式和 Circuitscape 不是一套 |
我一般的工作流是:先在界面里用一份小范围数据把参数试对,把界面生成的 ini 存下来当模板;然后把模板里的路径改成变量,扔进 Python 脚本批量跑;只有需要全景图的时候才切到 Omniscape。这样既能享受界面的即时反馈,又不会把几十组情景的重复劳动压在鼠标上。
有一点要提前说清楚:包里各组件的具体目录名、启动方式,不同打包版本会有出入。别硬套网上看来的路径,先看解压目录里有没有 README 或示例配置,照着示例改,比自己猜快得多。
2.2 三份输入数据的最低合格标准
Circuitscape 的输入只有三样:一张阻力面、一份节点文件、一个配置文件。听起来简单,翻车基本都翻在这三样上。
阻力面。必须是数值型栅格,而且值是“阻力”不是“适宜性”。值越大表示越难穿过,这是反直觉的地方——很多做物种分布的人手上拿的是适宜性图层,值越大越好,直接喂进去结果会整个反过来,廊道全落在本该避开的区域。如果只有适宜性数据,常见做法是做一次单调递减变换再转成阻力,比如取倒数或做线性反转,变换方式要在报告里写清楚,因为它直接决定结论。
坐标系也值得单独提一句。优先用投影坐标系,像元单位是米;用地理坐标系(单位是度)时,八邻域的对角线距离换算会失真,横竖方向一度和斜向一度的实际地面距离不一样,电阻累积就偏了。分辨率则决定了结论的粒度,30 米和 300 米跑出来的廊道宽度不可比,跨研究对比时这是硬伤。
NoData 是第三个雷。多来源图层拼接之后,NoData 值经常是 -9999、255、nan 混着用,软件只认配置里声明的那个。没统一的话,要么整片区域被当成“极高阻力”把电流全挡死,要么被当成 0 阻力变成一条假的高速通道。
节点文件。纯文本,两列(x y)或三列(ID x y),分隔符空格或制表符都行。三个硬性要求:坐标必须和栅格同一坐标系;节点不能落在 NoData 像元上;任意两个节点不能落进同一个像元——最后这条最容易被忽略,落进同一像元后两点间电阻是 0,输出矩阵里一堆零你还以为是软件坏了。
另外use_pixel_centers这个开关要留意。它决定节点坐标被解释成像元中心还是像元角点。节点是从栅格导出来的就设 True,是从别的矢量数据来的、又做过坐标系转换,就得实际验证一下落点对不对。
配置文件。ini 格式,分节。核心是四节:模式、栅格、连接方案、输出。下面直接给一份可改的。
2.3 用 pairwise 模式跑通第一个闭环
先把模式定在 pairwise——所有节点两两配对,每个配对单独解一次电流。这是最容易验证的模式,结果也最好解释。
[Circuitscape mode] data_type = raster scenario = pairwise [Habitat raster or graph] habitat_file = /data/proj/inputs/resistance_30m.tif habitat_map_is_resistances = True [Options for pairwise and one-to-all and all-to-one modes] point_file = /data/proj/inputs/nodes.txt use_included_pairs = False [Connection scheme for raster habitat data] connect_using_avg_resistances = True connect_four_neighbors_only = False [Options for raster habitat data] use_pixel_centers = True [Output options] output_file = /data/proj/out/scenario_A.out write_cumulative_current = True write_current = True write_volt_maps = False set_null_currents_to_nodata = True compress_grids = False [Options for parallel processing] parallelize = True max_parallel = 8几个键值得解释。habitat_map_is_resistances = True声明栅格值是阻力;如果你的图层是电导(值越大越好走),这里要改成 False,否则方向整个翻过来。connect_four_neighbors_only = False表示用八邻域,电流可以走对角线,更接近真实的扩散;改成 True 就只能走横竖,廊道会显得方正保守,粗分辨率数据上我倾向用四邻域,抑制那种贴着对角线钻出来的假捷径。parallelize配max_parallel开并行,但要注意瓶颈通常不是 CPU 而是内存,核数开太大反而容易被系统杀掉。
跑起来:
python -m circuitscape /data/proj/cfg/scenario_A.ini跑完之后,以output_file的主名为前缀会落下一组东西:scenario_A_resistances.out是节点两两之间的电阻距离矩阵,这是定量分析的主表;同目录下还有一个电流图目录,里面是每对节点的电流密度栅格;再加一张累计电流图,把所有配对的电流叠起来。如果只想要电阻矩阵、不要那一堆栅格,把write_current关掉,磁盘能省下一大截。
第一次跑,建议只放三到五个节点,跑完先看电阻矩阵是不是对称、对角是不是 0。这两个检查花不了一分钟,能挡掉后面八成的排查时间。
3. 把一次点击变成一条流水线:Python 侧批量驱动与结果解析
界面能跑通,不代表能交付。真实项目里往往是十几个阻力情景乘上几组节点配置,再乘上敏感性分析的几个变体,几十次计算起步。这个量级靠点鼠标既慢又不可复现——三个月后你根本说不清当时那个图是用哪一版阻力面跑出来的。这一章把 ini 生成、调用、结果解析串成一条能直接抄的流水线。
3.1 为什么批处理必须绕开图形界面
界面的问题不只是慢。它把参数状态保存在窗口里,而不是保存成一个可以进版本库的文本文件。你调了五次参数,最后只有最后一次的结果,中间那四次的过程信息丢了。而脚本化的核心价值就是:配置文件即实验记录。
还有两个现实原因。一是内存。大栅格跑 pairwise,单次计算的内存峰值可能好几个 G,用 subprocess 起独立进程,进程退出内存立刻还给系统;在同一个 Python 进程里反复调 API,内存碎片会一点点攒起来,跑到第十几个情景就 OOM。二是失败隔离,某个情景因为节点落进 NoData 挂了,subprocess 抛异常你捕获掉就行,不会把整个批处理进程带崩。
所以我的习惯是:用 Python 生成配置、用 subprocess 调用、用 numpy 读结果。三段解耦,每段都能单独测。
3.2 用模板生成 ini 再逐场景调用
先写一个模板函数。核心思路是把上一章那份 ini 变成带占位符的字符串,路径一律resolve()成绝对路径——相对路径是批量脚本里最隐蔽的坑,工作目录一变,读到的就是另一份文件。
from pathlib import Path import subprocess import sys INI_TMPL = """[Circuitscape mode] data_type = raster scenario = pairwise [Habitat raster or graph] habitat_file = {habitat} habitat_map_is_resistances = True [Options for pairwise and one-to-all and all-to-one modes] point_file = {points} use_included_pairs = False [Connection scheme for raster habitat data] connect_using_avg_resistances = True connect_four_neighbors_only = {four_nb} [Options for raster habitat data] use_pixel_centers = True [Output options] output_file = {out} write_cumulative_current = True write_current = {write_cur} write_volt_maps = False set_null_currents_to_nodata = True compress_grids = False [Options for parallel processing] parallelize = True max_parallel = {nproc} """ def build_ini(ini_path, habitat, points, out, four_nb=False, write_cur=True, nproc=4): ini_path = Path(ini_path) ini_path.parent.mkdir(parents=True, exist_ok=True) ini_path.write_text(INI_TMPL.format( habitat=Path(habitat).resolve(), points=Path(points).resolve(), out=Path(out).resolve(), four_nb=str(four_nb), write_cur=str(write_cur), nproc=int(nproc), ), encoding="utf-8") return ini_pathfour_nb和write_cur用字符串True/False而不是 Python 布尔值,因为 ini 解析器读的是字面量。nproc建议先给 4,跑通再往上加,加到机器开始频繁换页就说明过头了。resolve()那一行不是洁癖,是保命。
然后是调用器。这里用 subprocess 而不是直接 import,理由上面说了:内存回收干净、失败可隔离。
def run_cs(ini_path, timeout=7200): cmd = [sys.executable, "-m", "circuitscape", str(Path(ini_path).resolve())] r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) if r.returncode != 0: # 只留尾部,报错信息通常就在最后几百行 raise RuntimeError(f"failed: {ini_path}\n{r.stderr[-3000:]}") return r.stdouttimeout一定要给。我遇到过一次栅格范围填错,软件在解一个几乎无穷大的连通区域,进程安安静静挂了六个小时,日志一个字没有。给了超时之后,这种事最多浪费一次设定值。
批量的主循环就很简单了,把情景表写成字典列表,一个个过:
scenarios = [ {"name": "A_4nb", "habitat": "inputs/res_A.tif", "four_nb": True}, {"name": "A_8nb", "habitat": "inputs/res_A.tif", "four_nb": False}, {"name": "B_8nb", "habitat": "inputs/res_B.tif", "four_nb": False}, ] for sc in scenarios: ini = build_ini(f"cfg/{sc['name']}.ini", sc["habitat"], "inputs/nodes.txt", f"out/{sc['name']}.out", four_nb=sc["four_nb"], nproc=4) run_cs(ini)注意每个情景的输出主名必须不同,否则后一次会静默覆盖前一次——这是我最不想承认踩过的一个坑,跑了半天发现只有最后一个情景的结果。
3.3 解析电阻矩阵与电流图
结果文件有两种形态。矩阵形态是方阵,首行和首列是节点 ID;三列形态每行是节点A 节点B 电阻,解析起来更省事,做统计分析我基本都用它。
import numpy as np def load_pairs(path, skip_header=False): """读 *_resistances_3columns.out,返回 {(a,b): r} 字典""" arr = np.loadtxt(path, ndmin=2, skiprows=1 if skip_header else 0, comments="#") out = {} for a, b, r in arr[:, :3]: key = (int(a), int(b)) if a < b else (int(b), int(a)) out[key] = float(r) return out def to_matrix(pairs, ids): """把字典摊回方阵,方便做对称性检查""" idx = {nid: i for i, nid in enumerate(ids)} m = np.zeros((len(ids), len(ids))) for (a, b), r in pairs.items(): m[idx[a], idx[b]] = m[idx[b], idx[a]] = r return mskip_header这个参数留着是有用的:不同版本写出来的三列文件,有的带一行列名,有的不带,读之前用head -2看一眼比猜快。键统一排序成(小, 大)是为了后面做汇总时不重复计数。
电流图那边是标准栅格,用常规的栅格读取库打开就行。真正要注意的是累计电流图的量纲——它是所有配对电流的简单叠加,节点数一多,数值就跟着涨,所以跨情景比较之前必须先归一化:除以配对数,或者除以自身的分位数。直接拿两张累计电流图的绝对值比高低,是无效比较。
def normalize_current(arr, n_pairs): """累计电流除以配对数,得到可比的平均电流密度""" return np.where(np.isfinite(arr), arr / max(n_pairs, 1), np.nan)配对数就是n*(n-1)/2,n 是节点数。这一步做完,不同节点配置、不同情景的电流图才站在同一条起跑线上。
3.4 参数改动对照表
参数不是随便调的,每个都有自己的物理含义。下面这张表是我实际会动的几个,其余保持默认。
| 参数 | 常见取值 | 改了会怎样 | 什么情况下动它 |
|---|---|---|---|
connect_four_neighbors_only | False(八邻域) | True 时电流只走横竖,廊道更方正、更保守 | 分辨率粗、想压掉对角假捷径 |
connect_using_avg_resistances | True | False 时取相邻两格里更好走的那个值,整体连通性偏高 | 一般不动,属于少数派用法 |
use_pixel_centers | True | 改变节点坐标的解释方式 | 节点来自矢量、做过投影转换时要验证 |
write_current | True | 关掉只留电阻矩阵,磁盘省很多 | 大批量、只要定量指标时 |
set_null_currents_to_nodata | True | 背景 0 变 NoData,出图不糊成一片 | 出图必开 |
max_parallel | 视机器 | 加到内存打满为止,不是越多越快 | 大栅格先看内存再看核数 |
connect_using_avg_resistances这一行值得多说两句。它决定相邻两格之间的连接电阻取平均值还是取较优值。取平均值更接近“整个像元都在均匀阻挡”,取较优值相当于假设个体总能找到格子里最好走的那一小块,连通性会系统性偏高。两者都能用,但必须在方法里写清楚,不然不同研究之间没法比。
4. Omniscape 的滑动窗口:不分源汇的全景电流怎么算
pairwise 模式有个隐含前提:你得先知道哪些斑块是源、哪些是汇。区域尺度的规划项目里,这个前提经常不成立——你手上只有一张全区的阻力面,不知道哪块是“出发点”。Omniscape 就是为这个场景准备的:它在一个滑动窗口里,把窗口内所有低阻力像元都当成潜在源,逐块算电流,再把所有窗口的结果叠起来。不需要预先指定节点。
4.1 pairwise 在区域尺度上的两个硬伤
第一个是组合数。n 个节点要算 n(n-1)/2 次,五十个节点就是一千两百多次计算,每个节点对都要解一遍全网电流,时间随节点数平方增长。省域尺度的斑块动辄上百个,这条路走不通。
第二个更本质:源汇假设本身就是结论的一部分。你指定了哪几块是源,结果就绕着这几块转。换个专家来指定,结论可能就变了。而廊道规划要回答的恰恰是“哪一带整体上更容易连通”,不该被少数几个节点的选择绑架。
Omniscape 的解法是把“源”这件事弱化成一种连续权重:每个像元都有一个源强度,可以是二值的,也可以是栖息地质量归一化后的连续值。窗口滑动时,窗口内强度高的像元自然承担更多电流注入。这样既保留了电路理论的物理意义,又不需要拍板指定节点。
代价是计算量。滑动窗口意味着每个像元被重复计算很多次,参数没调好能把机器跑死。
4.2 半径、块大小、缓冲区怎么定
这三个参数是 Omniscape 的全部难点,其余都是常规配置。
radius(窗口半径,单位像元)。它等于你设定的最大扩散距离。填小了,廊道只在一个街区范围内成立,长距离扩散被截断;填大了,相当于假设个体一步能跨过半个省,算出来的电流会把所有区域糊成一片,没有分辨力。定这个值的依据应该是目标物种的扩散能力,而不是“看起来好看”。我的经验是先按文献里该物种的最大扩散距离折成像元数,再用一半和两倍各跑一次做敏感性,看结论稳不稳。
block_size(分块边长)。控制单次计算的内存占用。窗口滑动本身是逐像元推进的,但实现上会切块并行,块太大内存炸,块太小调度开销上来。
buffer(块间重叠宽度)。必须大于等于 radius,否则相邻两块的边缘会各自按自己的边界条件解一遍,拼起来就出现一条条规律的高电流或低电流缝——这个伪影非常像“廊道”,第一次见很容易当成重要发现,实际上是算法边界。
配置大概长这样,字段名以包内示例为准,我这里给的是结构:
[Inputs] resistances_file = "resistance_30m.tif" source_file = "source_strength.tif" [Outputs] output_dir = "out/omni_A" [Omniscape] radius = 40 block_size = 12 buffer = 60 mode = "rank" source_threshold = 0.0 calc_parallel = true write_raw = false correct_artifacts = truemode一般用rank,按窗口内电流排序做归一化,对极端值不敏感,跨区域可比性好。correct_artifacts保持开启,它专门处理窗口边缘的伪影。source_threshold用来把低于阈值的源强度当 0,避免大片低质量像元持续注入电流把背景抬高。
4.3 输出后处理:从原始电流到可比强度
Omniscape 输出的原始累计电流图不能直接当廊道图用。它是每个窗口结果的叠加,数值大小和窗口数、半径都强相关,换个参数就不可比。两件事必须做。
第一是归一化。按窗口覆盖次数(或者直接按分位数)折算,让结果落在可解释的区间里。
import numpy as np def omni_normalize(raw, valid_mask): """按有效像元的分位数归一,压掉极端值的影响""" v = raw[valid_mask & np.isfinite(raw)] p99 = np.percentile(v, 99) return np.clip(raw / p99, 0, 1) if p99 > 0 else raw用 99 分位而不是最大值,是因为裸露地和窄谷地形会偶尔冒出极高的单点,用最大值归一会把整张图压扁。
第二是分级。归一化之后按分位数切档,比如前 10% 划为核心廊道、10%–30% 为次级通道、其余为背景。分档线要在方法里写明,因为它是结论的一部分。我还会做一步交叉检查:把核心廊道区叠回阻力面,看它有没有大量穿过高阻力核心区。穿过去说明参数太松,廊道被“推”上了不该走的地方。
5. 避坑与排查:五类翻车现场和对应的检查动作
前面几章讲的是怎么跑对,这一章讲怎么跑错之后快速定位。下面五条都是按“现象→原因→解决”写的,遇到问题直接对号入座。
5.1 数据层面的三个坑
现象:跑完的电流图里,廊道全落在本该避开的区域。原因:阻力面方向反了。手头那份图层是适宜性(值越大越好),却按阻力(值越大越难走)喂了进去,于是“好地方”变成了“最难穿的地方”,电流全跑到边缘去了。 解决:先确认图层语义,再改habitat_map_is_resistances。如果源数据本来就是适宜性,做一次单调递减变换(取倒数或线性反转),并且把这个变换写进方法。判断方法很简单:找出已知的强阻力区(比如建设用地、裸岩),看它的值是不是全图最高的一档。
现象:电阻矩阵里大量 0 值,或者干脆全是 NaN。原因:节点坐标和栅格不在同一坐标系,或者use_pixel_centers设反了,导致多个节点落进同一个像元(距离 0),或者落到栅格范围外(NaN)。 解决:把节点坐标和栅格的范围叠在一起看一眼,五个节点画四个点就能发现。落进同一像元的节点,要么合并成一个,要么在像元尺度上重新布点。这一步我会在跑之前固定做一次,比跑完之后回溯省事得多。
现象:电流全部绕着一个巨大的空白区走,像有一堵墙。原因:NoData 没统一。拼接图层里这个用 -9999、那个用 255,软件只认声明的那个,另一种就被当成真实数值读进去了,可能是极高阻力也可能接近 0。 解决:跑之前统一重投影并统一 NoData 值,用一次栅格运算把所有非法值刷成同一个。同时把set_null_currents_to_nodata打开,出图时才不会把背景和真电流混在一起。
5.2 运行与输出层面的两个坑
现象:进程安安静静挂了几个小时,日志一动不动,最后被系统杀掉。原因:pairwise 模式组合数爆炸——节点数一多,计算量按平方涨;或者栅格范围大且阻力值分布极端,单次求解的迭代次数暴涨。CPU 并不是瓶颈,内存才是。 解决:三件事一起做。用use_included_pairs只算关心的节点对,把组合数压下来;把max_parallel降到 4 或 2,先确认不是内存问题;给 subprocess 加timeout,让失败快速暴露而不是静默挂死。区域尺度就直接换 Omniscape,不要硬扛。
现象:同一份配置,今天跑和昨天跑结果不一样。原因:ini 里用了相对路径,两次运行的工作目录不同,读到的其实是两份不同的输入文件。或者节点文件被改动过而没记录下来。 解决:所有路径在生成 ini 时resolve()成绝对路径,配置文件本身进版本库,和输出目录一一对应。这条听起来像洁癖,但它是唯一能让三个月后的你复现当时结论的办法。我现在连中间试验的 ini 都留着,占不了多少空间。
6. 结果自检:拿一条只有唯一解法的栅格当标尺
参数调对了没有,不能靠图好看判断。我习惯在跑真实数据之前,先拿一个人为构造、答案已知的小栅格做一次标定,相当于给软件做体检。
构造方法很直接:造一张 100×100 的栅格,背景阻力全填 100,再从 (10,50) 到 (90,50) 横向拉一条一像元宽的低阻力通道,值填 1。两个节点分别放在通道两端。因为四周全是高阻力,电流没有别的路可走,通道就是唯一路径。这时候总电阻应该约等于通道长度乘单元阻力——从第 10 列到第 90 列是 80 个连接,理论值约 80。
跑出来如果落在 78–82 这个量级,说明阻力语义、连接方案、坐标解释三件事都是对的。如果差得离谱,往下查的方向就明确了:数值翻倍通常是八邻域在通道两侧通过高阻力像元形成了并联路径,或者connect_using_avg_resistances的设置和你以为的不一样;数值差一个数量级基本是阻力单位搞错了(是每像元还是每米);数值接近 0 则是两个节点被塞进了同一个像元。
这个标定还有个附带好处:它能帮你判断四邻域和八邻域在你的数据分辨率下差多少。同一条通道,四邻域会给出接近 80 的干净结果,八邻域略低一些,差值就是对角并联路径的贡献。这个差值有多大,直接决定你在正式数据上该选哪个方案——差值小就随大流用八邻域,差值大就说明你的像元太粗,对角捷径会实质性改变结论。
三个定量自检指标,跑完真实数据我都会过一遍。第一,电阻矩阵必须对称,对角为 0,且满足三角不等式——电阻距离是一个度量,任意三点之间R_ij ≤ R_ik + R_kj必须成立,不成立就是数值出问题了。第二,电流守恒,把所有源注入的电流加起来,和汇流出的总量应当抵消,累计电流图的全局量级应该随配对数线性增长,如果不是,多半有配对没跑完。第三,把电流高值区和最小成本路径叠在一起,前者应当覆盖或包住后者;如果两者完全不重叠,先别怀疑电路理论,回头查坐标系。
这套体检流程我走了很多次之后已经成了肌肉记忆:新数据进来,先跑标定栅格,再看对称性,最后才看真实结果。有一次赶进度跳过标定,直接拿一张投影定义错误的阻力面跑了三天,出来的廊道漂亮得不像话,全部落在研究区外边——从那以后我每次换数据集,都强制走一遍这条标定加三项检查的流程,宁可多花二十分钟。希望帮到你。
本文还有配套的精品资源,点击获取