第13章 - 模型构建器与自动化工作流
一次分析做完了,第二天要在另一份数据上再做一遍——这是 GIS 日常里最重复、最容易出错的部分。逐个对话框点过去,参数记不住,中间图层丢不掉也管不住。Model Builder(模型构建器) 把这条流程画成一张图:节点是工具,连线是数据流,整张图当作一个作业跑完。
本章基于 GeoLibre 3.4 编写,逐项对照 docs/user-guide/model-builder.md 与三份实现源码:packages/processing/src/model-graph.ts(图引擎)、apps/geolibre-desktop/src/lib/model-tool-catalog.ts(工具目录适配)、apps/geolibre-desktop/src/lib/model-python-script.ts(Python 导出)。界面上出现的英文控件名一律保留原文。
13.1 定位:它解决什么问题、不解决什么问题
13.1.1 三条被反复踩到的痛点
| 痛点 | 逐个对话框的后果 | Model Builder 的做法 |
|---|---|---|
| 参数不可复用 | 每次重跑都要重新回忆距离、单位、阈值 | 参数写在节点上,随模型保存进 .geolibre.json |
| 中间结果污染图层栈 | 跑完一次多出四五个 Buffer、Clip 图层 |
只有接到 Output 节点的结果才会变成图层 |
| 依赖关系靠记忆 | 忘了先投影、先填洼,结果错在最后一步 | 连线即依赖;校验器在运行前就报出”缺输入” |
第三点最值钱:模型图是显式的依赖声明,而不是一段有顺序的脚本。引擎会自己做拓扑排序(topologicalOrder,Kahn 算法),你摆节点的位置跟执行顺序无关。
13.1.2 与其它四种”自动化”的分工
GeoLibre 里能”一次跑一串”的地方不止一处,选错的代价是白折腾:
| 方式 | 入口 | 适合 | 不适合 |
|---|---|---|---|
| Model Builder | Processing → Model Builder | 有分叉、有多输入工具、要在图上反复调的流程 | 需要循环/迭代、需要按文件批量跑 |
| Batch tools | Processing → GeoLibre Toolbox → Batch tools | 同一个工具套在很多个输入图层上(Batch 模式) | 多工具串联、多输入汇合 |
| Processing History | Processing → History | 重跑一次刚跑过的工具,或”Edit & re-run”改一个参数 | 建立可交付、可复用的流程 |
| SQL Workspace | Processing → SQL Workspace | 集合式的关系运算、聚合、连接,一次处理整表 | 依赖多个工具输出串接的算符链 |
| Python / Notebook | Processing → Python Console、Jupyter Notebook | 需要循环、条件、外部库、写文件 | 只想把界面上摆好的流程存下来复用 |
一句话判断法:流程有分叉且有第二个输入 → Model Builder;只是同一个工具重复很多次 → Batch;全部是集合运算 → SQL;需要真正的控制流 → Python。
注意:Model Builder 的执行器不使用 Python sidecar。矢量工具在浏览器里跑,Whitebox 工具走浏览器内的 WebAssembly runner——桌面版也一样。这意味着模型能不能跑,取决于浏览器端注册表里有没有这个工具,而不是你装没装 sidecar。
13.1.3 明确不存在的东西
官方文档在 Nodes 一节写得很干脆,这里再强调一次,因为它决定了建模的思维方式:
- 没有值节点 / 参数节点:常量只能写进工具自己的参数面板。
- 没有循环、迭代器、条件分支:图是纯数据流(纯 DAG),引擎检测到环就整体拒绝。
- 没有撤销、没有复制粘贴:这是目前最容易吃亏的一条。文档原话是”export a copy before a large rework”——大改之前先 Export 一份。
警告:不要在 Model Builder 里期待”遍历某个字段的每个值”。想那样做,要么走 Batch tools,要么导出 Python 脚本自己写循环。
13.2 界面:一块覆盖在地图上的浮动面板
13.2.1 布局与可拖拽的分栏
Processing → Model Builder 打开的面板浮在地图之上,可以移动、缩放、最大化、最小化。内部固定四块:
┌──────────────────────────────────────────────────────────────┐
│ 模型名 New Save Arrange Import Export Copy Python Run │ ← 标题栏
├───────────┬────────────────────────────┬─────────────────────┤
│ 工具调色板 │ 画布(节点图) │ 选中节点的设置面板 │
│ Search… │ │ │
├───────────┴────────────────────────────┴─────────────────────┤
│ 消息日志(每个工具自己的输出) │
└──────────────────────────────────────────────────────────────┘
四块之间的分隔条都能拖。尺寸约束直接来自 ModelBuilderPanel.tsx 的常量,知道这些数字有助于理解”为什么拖不动了”:
| 常量 | 值 | 约束对象 |
|---|---|---|
MIN_SIDE_WIDTH / MAX_SIDE_WIDTH |
140 / 480 px | 左右两栏宽度 |
DEFAULT_PALETTE_WIDTH / DEFAULT_INSPECTOR_WIDTH |
208 / 224 px | 两栏初始宽度 |
MIN_LOG_HEIGHT / MAX_LOG_HEIGHT |
56 / 420 px | 日志区高度,默认 96 px |
MAX_LOG_FRACTION |
0.6 | 日志最多占面板高度的六成,保证画布还在 |
MAX_SIDE_FRACTION |
0.4 | 单侧栏最多占面板宽度的四成 |
MIN_WIDTH / MIN_HEIGHT |
820 / 420 px | 面板的偏好下限 |
FLOOR_WIDTH / FLOOR_HEIGHT |
260 / 220 px | 绝对下限(再小就没法用了) |
COMPACT_WIDTH |
720 px | 低于此宽度进入紧凑模式 |
紧凑模式(面板宽度 < 720 px)下,调色板和设置面板不再并排,而是变成标题栏上的切换按钮,一次只叠一层浮在画布上,画布拿回全部宽度。窄屏笔记本上这是常态,不必以为是 bug。
13.2.2 标题栏控件逐项说明
| 控件 | 行为 |
|---|---|
| Model name | 模型名,未命名时显示 Untitled model。 |
| New | 清空画布开新模型;当前模型有未保存改动时先问一次。 |
| Save | 存进项目文件(.geolibre.json),随项目走。 |
| Arrange | 按数据流从左到右自动摆放节点。会覆盖你手工摆的位置。 |
| Import / Export | 读写 .model.json 文件。 |
| Copy Python script | 复制等价的 geolibre Python 代码(只有当前模型成功跑过之后才可用)。 |
| Run / Cancel | 运行整张图,或停掉正在跑的这次。 |
Run 在校验器报出任何问题时是禁用状态(disabled),不是点了之后弹错误。
13.2.3 画布本身的两个反直觉点
- 画布只平移,不缩放。 拖空白处滚动视口;没有缩放手势。节点多了就靠 Arrange 重排。
- 节点卡片可以拖着摆位置,
x/y存的是图坐标(不随视口滚动变化),保存在ModelGraphNode的x、y字段里。
节点卡片上有三段信息:最上一行是 provider(vector 或 whitebox 的显示名),中间是工具名,下面是端口行。工具名在调色板里的显示名来自注册表(矢量工具用 algorithm.name,Whitebox 用 display_name,缺失时回落到工具 id)。
13.2.4 消息日志
日志区追加显示每个工具自己 ctx.log() 出来的行,加上引擎的两类固定格式:
Running Buffer...
Buffer: produced 128 feature(s)
Running Clip...
Error: both layers must contain polygon features
引擎自己写 Running <tool name>... 和 Error: <message>。工具内部的日志(比如 Buffer: produced 128 feature(s))原样透传。日志是排错的第一现场——很多”结果为空”的模型,答案就在这一行 produced 0 feature(s) 里。
13.3 图的三种节点与三种端口类型
13.3.1 Input / Tool / Output
| 节点类型 | kind |
端口 | 必填字段 | 作用 |
|---|---|---|---|---|
| 输入 | input |
只有一个输出口,id 固定为 out,类型 any |
layerId |
从项目里取一个图层,喂给下游 |
| 工具 | tool |
由工具描述符决定,可有多个输入、多个输出 | toolId + provider |
执行一个处理算法 |
| 输出 | output |
只有一个输入口,id 固定为 in,类型 any,且 required: true |
name |
把结果加成地图图层 |
三个常量在源码里是硬编码的:
/** The single output port every input node exposes. */
export const INPUT_NODE_PORT = "out";
/** The single input port every output node exposes. */
export const OUTPUT_NODE_PORT = "in";
+ Input 和 + Output 各加一个对应节点。Input 节点的设置面板里只有一个 Source layer 下拉框;Output 节点的只有一个 Result name 输入框。Output 的 name 留空时,落地图层名回落到 Model output。
13.3.2 端口类型:vector / raster / any
线上流动的值是一个带标签的联合体(ModelValue):
export type ModelValue =
| { kind: "vector"; geojson: FeatureCollection }
| { kind: "raster"; bytes: Uint8Array; name?: string };
矢量节点之间传 FeatureCollection,栅格节点之间传 GeoTIFF 字节。标签随载荷一起走,这正是同一张图里能混排 Whitebox 栅格工具和客户端矢量工具、同时在类型不匹配时立刻失败的原因。
兼容规则只有三行:
export function portKindsCompatible(from: ModelPortKind, to: ModelPortKind): boolean {
return from === "any" || to === "any" || from === to;
}
即:any 是通配符;否则必须同类。Input / Output 节点固定 any,所以它们能接任何东西——这也是为什么”Input 接错工具”这类错误不会在校验期报出来,而是在运行期以工具内部错误的形式出现。
13.3.3 调色板里的工具从哪来
调色板是两个注册表的并集,由 buildModelToolCatalog 合并:
export function buildModelToolCatalog(
vectorTools: ProcessingAlgorithm[],
whiteboxTools: WhiteboxTool[],
): ModelToolDescriptor[] {
const descriptors: ModelToolDescriptor[] = vectorTools.map(vectorToolDescriptor);
for (const tool of whiteboxTools) {
if (tool.locked) continue; // pro 档工具直接剔除
const descriptor = whiteboxToolDescriptor(tool);
if (descriptor) descriptors.push(descriptor);
}
descriptors.sort((a, b) => a.group.localeCompare(b.group) || a.name.localeCompare(b.name));
return descriptors;
}
三个”会被静默丢掉”的分支,值得记住:
locked为真的 Whitebox 工具被剔除。它们跑不了,放上画布只会做出一个在最后一步失败的模型。本机whitebox-catalog-snapshot.json快照共 775 条工具,其中 42 条带locked: true。- 没有输出端口的工具返回
null被跳过——下游没有东西能消费它。 - LiDAR 与文件参数(
lidar_in/file_in/file_out)不会变成端口,只会变成设置面板里的路径输入框。原因还是那句:模型值只有 vector 和 raster 两种。
另一侧的客户端矢量工具来自 VECTOR_TOOLS(vector-tool-registry.ts),当前是 37 个:26 个 Turf 几何/叠加/连接/选择工具,3 个 DGGS 工具,3 个移动与时间工具,1 个图层合并工具,4 个数据质量工具(有效性检查、几何修复、拓扑规则检查、拓扑修复)。
提示:调色板区分同名工具靠的是
key而不是toolId。key的构造是"<provider>:<toolId>",例如vector:buffer与whitebox:buffer是两个不同的条目。保存的节点里toolId和provider分开存,正是为了让同一个短 id 在两个注册表里共存。
13.3.4 搜索调色板
Search tools 是大小写不敏感的子串匹配,匹配范围是四个字段拼起来的字符串:name、toolId、group,外加 UI 传入的本地化文本:
return descriptors.filter((descriptor) =>
${descriptor.name} ${descriptor.toolId} ${descriptor.group} ${localizedText?.(descriptor) ?? ""}
.toLowerCase()
.includes(needle),
);
实用推论:搜 terrain 能找到 slope,因为分组名里有;搜中文词能不能命中,取决于当前语言下的本地化文本——调色板渲染的是翻译过的名字和分组,所以它把翻译文本也一并传进搜索。
13.4 从零搭一个模型:加载 → 缓冲区 → 叠加 → 导出
下面这套流程走完,你会得到一个”道路两侧 100 米、且裁到研究区内”的图层。每一步都说明了参数来自哪个字段。
13.4.1 第 1 步:准备输入图层
- 确认项目里已有两个矢量图层:roads(LineString)与 study_area(Polygon)。
- 打开 Processing → Model Builder。
- 点 + Input,在新节点右侧设置面板的 Source layer 里选 roads。
13.4.2 第 2 步:拖入 Buffer 工具
- 在 Search tools 里输入
buffer。 - 按住结果里的 Buffer(分组 Geometry,provider 显示为矢量注册表)拖到画布。
- 也可以单击它,或者选中后按
Enter,工具会加在默认位置。
13.4.3 第 3 步:连线
- 从 Input 节点右侧的输出口 out 按下鼠标,拖到 Buffer 节点左侧的输入口。
- 松手。连线出现。
- 连错了:点那条曲线的任意位置即可删除。
- 键盘操作:先激活输出口把它”上膛”,再激活输入口完成连接。
Buffer 的输入口 id 是 layer(来自算法定义里的第一个参数),类型 vector,required: true。
13.4.4 第 4 步:填参数
选中 Buffer 节点,右侧面板列出剩下的参数(连线已经接管了 layer,所以它不再需要手工填,但仍在面板里显示,原因见 13.5.4):
| 参数 id | 面板标签 | 类型 | 默认值 | 说明 |
|---|---|---|---|---|
distance |
Distance | number | 1 | 必填,min: 0,步长 0.1 |
units |
Units | select | kilometers |
可选 kilometers / meters / miles |
side |
Buffer side | select | outside |
outside 外扩、inside 内缩、both 双侧 |
dissolve |
Dissolve result | boolean | false | 合并成一个要素并融掉重叠;属性会丢 |
本例填:Distance = 100,Units = meters,Buffer side = outside,Dissolve result = false。
注意:
inside(内缩)需要面输入。点或线没有内部可供内缩,工具会报错而不是返回空结果。
13.4.5 第 5 步:接第二个工具做裁剪
- 再加一个 + Input,Source layer 选 study_area。
- 搜索
clip,把 Clip(分组 Overlay)拖到画布。 - Buffer 的
out→ Clip 的layer(Input layer)。 - study_area 的 Input 节点
out→ Clip 的overlay(Overlay (clip) layer)。
Clip 的两个输入口都带 geometryFilter: ["polygon"]——如果 Buffer 的 side 选了 inside 而输入是线,上游就没面可裁,运行期会报”both layers must contain polygon features”。本例 outside 出的是面,没问题。
13.4.6 第 6 步:加 Output 并命名
- 点 + Output。
- Clip 的
out→ Output 的in。 - Result name 填
Roads buffer 100m (clipped)。
13.4.7 第 7 步:运行与查看
- 确认 Run 已经可点(不可点说明校验器还有问题,去对应节点的设置面板看红字)。
- 点 Run。节点卡片依次变成 running → done。
- 日志里应出现三行工具日志加若干
Running …。 - 图层栈里多出一个
Roads buffer 100m (clipped)的 GeoJSON 图层。
13.4.8 第 8 步:保存与导出
- Model name 填
Roads buffer。 - Save——存进项目。
- Export——下载
Roads buffer.model.json(文件名由 slug 化后的模型名决定,slug为空时回落到model)。
13.5 参数与类型匹配
13.5.1 Whitebox 参数如何映射成面板控件
whiteboxScalarParameter 是这条链路上唯一的翻译层。它按 parameterKind(param) 归一化后的科属做映射:
| 归一化 kind | 面板类型 | 备注 |
|---|---|---|
bool |
boolean |
复选框 |
int / double |
number |
保留 required、default、description |
enum |
select |
选项直接把 options 数组搬过来 |
string 且参数名像字段名 |
field |
见 13.5.2 |
string |
string |
自由文本 |
其它(lidar_in / file_in / file_out 与未识别) |
path |
文件路径输入框 |
*_field 类参数的识别靠 isFieldParameterName(在 whitebox-field-params.ts)。判断出是字段参数后,面板要决定”从哪个输入图层取字段列表”,这段逻辑是:
const fieldSource =
vectorInputNames.length === 1
? vectorInputNames[0]
: fieldSourceInputName(param.name, vectorInputNames);
return { ...base, type: "field", ...(fieldSource ? { fieldSource } : {}) };
只有一个矢量输入时直接用那个;有多个时靠参数名猜(fieldSourceInputName);猜不出来就不设 fieldSource,面板改为把所有矢量输入图层的字段并排列出,而不是瞎猜一个。
13.5.2 矢量工具的参数如何映射
矢量工具走 vectorToolDescriptor,规则更简单:type: "layer" 的参数既变成输入端口,又保留在参数列表里。
for (const param of algorithm.parameters) {
if (param.type === "layer") {
inputs.push({ id: param.id, label: param.label, kind: "vector", required: param.required });
// Still offered in the properties panel, so a single-node model can name a
// project layer without drawing an input node for it.
parameters.push(param);
continue;
}
parameters.push(param);
}
输出端口固定一个:id "out",label "Output",kind vector。注意这意味着矢量工具在模型里永远只能有一个输出——客户端 Turf 运行器就产出一个 FeatureCollection,模型引擎没有为多输出矢量工具留位置。
13.5.3 Whitebox 的多输入多输出
Whitebox 工具的端口是从参数清单里按 kind 推断出来的:
| 参数 kind | 变成 | 端口类型 |
|---|---|---|
raster_in |
输入端口(id 即参数名) | raster |
vector_in |
输入端口(id 即参数名) | vector |
raster_out |
输出端口 | raster |
vector_out |
输出端口 | vector |
这就是为什么 viewshed 这样的工具在模型里有两个输入口(input 栅格、stations 矢量);而真正的多输出工具会在画布上长出多个输出口,例如 ridge_and_valley_vectors(output_ridges、output_valleys)与 flow_accum_full_workflow(breached_dem_output_path、flow_dir_output_path、output)。反过来,openness 的 pos_output/neg_output 在清单里的 kind 是 string,不会成为端口,只能留在参数面板里。
注意:
native字段必须原样带上原始 manifest。Whitebox WASM runner 是遍历这份 manifest 的 params 拼命令行的;节点一旦丢掉它,就变成”零参数”调用,二进制会以缺少必填参数直接拒绝。源码注释对此说得很直接:a node that loses it runs with no arguments at all。
13.5.4 未连线端口用参数层兜底
有一处设计很容易被忽略,但它决定了”单节点模型”能不能用:
// An unwired input port may still name a project layer typed into the
// properties panel, which is how a single-node model gets its data.
for (const port of descriptor.inputs) {
if (inputs[port.id]) continue;
const typed = node.parameters?.[port.id];
if (typeof typed !== "string" || !typed) continue;
const value = await options.resolveInput(typed);
if (value) inputs[port.id] = value;
}
即:已经连线的端口优先,没连线的端口如果参数值是一个图层 id 字符串,就把它当作图层解析。所以只放一个 Buffer 节点、在它的 Input layer 里直接选项目图层,是可以跑的——这正是 13.1.1 里”单工具模型”的最短路径。
同一个机制在校验器里有对称的一段:必填端口既没有入边、参数值又是 undefined/null/"" 时,报 "port" needs a connection or a value.。
13.5.5 类型不匹配的实际表现
三种典型错配,报错位置不同:
| 错配 | 报在 | 提示 |
|---|---|---|
| 矢量输出 → 栅格输入 | 校验期,边 | Those ports carry different kinds of data. |
| Input 节点接了栅格图层但下游是矢量工具 | 运行期,工具内部 | 工具自己的日志,如 Error: both layers must contain polygon features |
| 同一工具同一输入口接了两条线 | 校验期,边 | “port” already has an incoming connection. |
第一、三类在运行前就能看到;第二类只能靠运行日志。所以模型第一次跑之前,先确认 Input 的 Source layer 是矢量的。
13.6 校验器:十种问题逐条对照
validateModelGraph 返回一个 ModelGraphIssue[],每条带机器可读的 code、可选 nodeId / edgeId、以及一个 detail(通常是被点名的端口标签或工具 id)。UI 用 code 查翻译,所以中文界面上看到的是翻译后的句子。
code |
文档里的英文原文 | 触发条件 |
|---|---|---|
missing-layer |
Choose an input layer. | input 节点没有 layerId |
missing-input |
“port” needs a connection or a value. | 必填输入口既无入边、参数也为空 |
duplicate-input |
“port” already has an incoming connection. | 两条边指向同一个输入口 |
type-mismatch |
Those ports carry different kinds of data. | portKindsCompatible 返回 false |
cycle |
The model contains a loop. | 拓扑排序节点数少于总数 |
no-output |
Add an output node to keep a result. | 图里没有任何 output 节点 |
unknown-tool |
Unknown tool “tool”. | 描述符解析不到 |
unknown-port |
(连接指向已不存在的端口) | 边上的 fromPort / toPort 找不到 |
dangling-edge |
(连接指向已不存在的节点) | 边的一端不在 nodes 里 |
duplicate-node |
(两个节点共用一个 id) | node.id 重复 |
表格里最后两条的说明值得单独看,因为它们的注释解释了”为什么不能静默跳过”:
dangling-edge:所有查找都以节点 id 为键,悬空边如果不报,只会”连线悄悄消失”;而graphToLinearSteps统计前驱时又会把它算进去,两处行为不一致。duplicate-node:重复 id 会让所有以 id 为键的查找按”后写胜出”塌缩,两个节点都还在nodes里,但运行时只执行一个,任何地方都不会报错。
13.6.1 拓扑排序:Kahn 算法与一个性能细节
topologicalOrder 用的是标准 Kahn:入度归零的节点入队,逐个出队并给后继减入度。返回 null 表示有环。有人可能会疑惑为什么不用 shift():
// Index cursor rather than shift(): shift() is O(remaining) per call, which
// makes a wide fan-out quadratic, and this runs on every graph edit.
for (let head = 0; head < queue.length; head++) {
因为这个函数每次图变动都跑一遍(保持校验实时),用 shift() 会让宽扇出的图退化成二次复杂度。这条注释是读源码时能拿到的、文档里没有的工程细节。
13.6.2 校验是实时的
不需要点”检查”。画布每次编辑后重跑一遍 validateModelGraph,把该节点的所有问题以红字列在它自己的设置面板里。所以修一个错,红字立刻跟着变。
13.7 中间结果保留策略
默认行为很简单,也很容易被误解:
只有连到 Output 节点的结果会被保留,中间的一切在运行结束后丢弃。
这句话的后果:一个五节点的模型跑完,图层栈里只会多出你接的那一两个 Output。好处是图层栈干净;代价是调参时看不到中间量。
13.7.1 两种保留手法
| 手法 | 操作 | 结果 |
|---|---|---|
| 保留某个中间结果 | 选中该工具节点,点 Keep this result | 自动新建一个 Output 节点并连好线 |
| 保留多输出工具的某个口 | 点 Keep “port”(按钮文案带端口名) | 同上,但连的是你选的那个口 |
按钮文案之所以带端口名,是因为多输出工具(ridge_and_valley_vectors、flow_accum_full_workflow、topological_breach_burn 之类)必须让你指明保哪个。
13.7.2 版本兼容与”链式投影”
项目里 ProcessingModel 同时有两种形状:
export interface ProcessingModel {
id: string;
name: string;
steps: ProcessingModelStep[];
graph?: ProcessingModelGraph;
}
steps 是早期的严格线性链,只有老版本读得懂;graph 是画布的图。两者都在时 graph 优先。为了兼顾老版本,画布保存时会尝试把图投影成 steps——但只在图确实是一条无歧义的链时才写。
graphToLinearSteps 的拒绝条件写得很严格,逐条列出来是因为它解释了”为什么我的模型在老版本里跑不了”:
- 有环 → 返回空数组。
- 不是恰好一个
input节点、恰好一个output节点 → 返回空数组。 input节点的出度不等于 1 → 返回空数组。output节点的入度不等于 1 → 返回空数组。- 任一
tool节点入度或出度不为 1 → 返回空数组。 - 任一
tool节点的provider不是vector,或没有toolId→ 返回空数组。
第 3、4 条的存在理由在注释里说得很清楚:一条分支如果穿过共享的 input 或 output 节点,每个工具节点的入度/出度仍然是 1,逐节点检查会放它过关,然后 runModel 会把两条分支当成一条串行链跑——正是这个函数存在要拒绝的”静默截断”。
返回空数组是”诚实的答案”:这种模型没有线性等价物,老版本不应该跑一个被截断的版本。
13.8 运行、取消与失败处理
13.8.1 执行模型
runModelGraph 按拓扑序逐个执行节点。执行一个节点时:
- 从入边收集上游值,键是目标端口 id。
- 若是
input:解析layerId;解不出就报Input layer "<id>" has no usable data.并整体中止。 - 若是
output:取in端口的值;没有就报Output node has nothing connected to it.;有就emitOutput(name, value, node)并记进outputs。 - 若是
tool:解析描述符 → 用参数兜底补齐未连线的输入 → 写Running <name>...→ 调executeTool→ 存进produced。
onNodeStatus(running / done / error)驱动卡片的外观。
13.8.2 失败与取消的边界
| 情形 | 行为 |
|---|---|
| 某节点抛错 | 停在该节点,日志追加 Error: <message>,返回 { outputs, error: { nodeId, message } } |
| 失败前已产出的 Output | 保留在地图上,可检查 |
| 点 Cancel | 在下一个节点启动前停下,返回 Run cancelled. |
| Whitebox 工具已在运行 | 先跑完,取消在它结束后生效 |
| 环 | 引擎直接返回 The model contains a loop.,不执行任何节点 |
最后一条要注意:虽然校验器会拦住带环的图,runModelGraph 自己仍然独立检查了一遍,并且默认调用方已经校验过(源码里写着 Validate first: this assumes the graph is acyclic)。
13.8.3 结果落地的形态
| Output 的值类型 | 落成什么图层 |
|---|---|
vector |
GeoJSON 图层 |
raster |
GeoTIFF 图层(WASM 工具输出本就是 COG) |
13.9 保存、分享与复用
13.9.1 存在项目里
Save 把模型写进项目的 .geolibre.json。项目下方的 Saved models 列出全部已保存模型:
- 从 Load a saved model… 里选一个打开。
- Delete 删掉保存的那份——画布保持原样,不会跟着清空。
13.9.2 .model.json 文件格式
Export 下载 <name>.model.json。文件结构:
{
"$schema": "https://geolibre.app/schemas/model-graph-v1.json",
"version": "1.0.0",
"name": "Roads buffer",
"graph": { "nodes": [ ... ], "edges": [ ... ] }
}
节点与边的实际字段:
{
"nodes": [
{ "id": "n1", "kind": "input", "x": 40, "y": 120, "layerId": "layer-8f2" },
{ "id": "n2", "kind": "tool", "x": 280, "y": 120,
"provider": "vector", "toolId": "buffer",
"parameters": { "distance": 100, "units": "meters", "side": "outside", "dissolve": false } },
{ "id": "n3", "kind": "output", "x": 520, "y": 120, "name": "Roads buffer 100m" }
],
"edges": [
{ "id": "e1", "from": "n1", "fromPort": "out", "to": "n2", "toPort": "layer" },
{ "id": "e2", "from": "n2", "fromPort": "out", "to": "n3", "toPort": "in" }
]
}
13.9.3 导入时的四道闸门
| 检查 | 阈值 / 条件 | 失败后果 |
|---|---|---|
| 文件大小 | MAX_IMPORT_BYTES = 16 MB |
拒绝;先查大小再读文件,避免把病态文件整个载入内存 |
$schema |
存在时必须等于 MODEL_SCHEMA |
拒绝,防止把随便一个 JSON 当模型 |
| 节点数 | MAX_IMPORT_NODES = 2000 |
拒绝 |
| 边数 | MAX_IMPORT_EDGES = 4000 |
拒绝 |
四个上限都是”手工搭的模型远远够不到”的量级。16 MB 这个数字的注释解释得很具体:节点/边的上限只有解码并解析完整个文件之后才可能触发,所以字节上限必须在读之前就检查。
13.9.4 结构清洗:normalizeModelGraph
导入的 JSON 是不可信输入。normalizeModelGraph 会:
- 丢弃没有可用
id、或kind不在input/tool/output里的节点。 - 重复的节点 id 只留第一个。
- 坐标用
normalizeCoordinate强制成有限数,否则归 0。 provider只有在vector/whitebox里才保留。- 丢弃
id重复、端点不存在、from === to(自环)、端口名为空的边。 - 一条都不剩时返回
null。
它不检查环、类型不匹配、必填输入缺失——源码注释说明了原因:这些依赖工具注册表,而项目层刻意不 import 注册表。结构合法性归运行器管。
13.9.5 跨项目复用的那个坑
Input 节点存的是图层 id,不是图层名。所以把模型导入另一个项目后:
必须重新给每个 Input 节点选一次 Source layer。
没选的时候校验器报 Choose an input layer.——这是设计使然,不是 bug。同理,如果原项目里的图层被删了再打开,layerId 还在但解析不出数据,运行期报 Input layer "..." has no usable data.。
13.10 Python 导出脚本
13.10.1 触发条件与内容
Copy Python script 在当前模型成功跑过一次之后才可用。生成的脚本只有几行:
# Generated by GeoLibre Model Builder.
# Run with the model's input layers loaded in the displayed map.
m.run_model_builder({"nodes": [...], "edges": [...]})
要点:
m是一个已显示的geolibre.Map——在 notebook 里或 Processing → Python Console 里。模型的输入图层必须已经加载到那个 map 上。- 整张图被原样序列化进调用,不是翻译成一个个
m.buffer(...)调用。也就是说 Python 侧拿到的是同一张图,由应用校验并执行。 - 返回的是输出图层的 id 列表。
run_model_builder有一个可选timeout,单位秒,默认 600。
13.10.2 为什么是”整图传递”而不是”逐句翻译”
生成器 modelGraphToPython 的实现回答了这个问题——它先做两件事,然后只拼一行调用:
const ordered = topologicalOrder(graph);
if (!ordered) throw new Error("Cannot generate Python for a model containing a loop.");
// Resolve every tool now, while the successful run's catalog is still available.
for (const node of ordered) {
if (node.kind === "tool" && !resolveDescriptor(node.provider, node.toolId)) {
throw new Error("Cannot generate Python for unknown tool.");
}
}
return "# Generated by GeoLibre Model Builder." + ... + "m.run_model_builder(" + pythonLiteral(graph) + ")";
两个前置校验(无环、工具都还在)只在复制时做一次,因为那是导出此刻唯一还掌握着成功运行目录的时机;宿主在真正执行前会再校验一遍。逐句翻译的做法做不到这点:Whitebox 工具的参数是 WASM runner 遍历 manifest 拼出来的,Python 侧没有等价物。
13.10.3 pythonLiteral 与跨语言陷阱
把 JS 值转成 Python 字面量看起来简单,实际有几处必须处理:
| JS 值 | Python 字面量 | 理由 |
|---|---|---|
null / undefined |
None |
undefined 在 Python 里不存在 |
true / false |
True / False |
大小写不同 |
非有限数(NaN、Infinity) |
None |
Python 没有对应的字面量写法 |
| 字符串 | JSON.stringify(value) |
JSON 字符串字面量与 Python 兼容 |
| 数组 | [a, b, c] |
递归 |
| 对象 | {"k": v} |
递归,且跳过值为 undefined 的键 |
最后那条”跳过 undefined 键”很关键:JS 对象里 {"a": undefined} 序列化出去会变成 {"a": None},而在 Python 侧 None 是一个显式的空值参数——语义上变成了”用户明确设了这个参数为空”,而不是”这个参数没设”。两者在工具的参数校验里走不同分支。
13.10.4 在桌面端跑导出的脚本
需要两个前提:桌面版(或带 sidecar 的部署),以及一个已显示的 geolibre.Map。
import geolibre as gl
# 假设当前 notebook / console 已连上应用里的那张地图
m = gl.Map() # 具体构造方式见第 19 章
ids = m.run_model_builder(graph, timeout=900)
print(ids)
提示:
timeout默认 600 秒对栅格链往往不够。一个 4096×4096 的 DEM 做填洼 + 流向 + 流量累积 + 河网提取,单步就可能几十秒,四步串起来很容易超时——先把它调大,再考虑降分辨率。
13.11 用 AI 助手生成模型
13.11.1 两个工具的分工
AI 助手不是”看一眼就瞎写”,它必须走两步:
| 工具 | 作用 | 关键约束 |
|---|---|---|
list_model_algorithms |
列出可放进模型的算法,带精确的输入端口 id 与参数 id | 目录约 1000 个工具,必须传 search 过滤 |
create_model_builder_model |
建图、保存、打开 Model Builder | 输入/步骤用短 key 互相引用 |
系统提示里对这条路径的措辞很硬:do not execute the pipeline immediately… save a validated editable graph and open it for review. 也就是说助手建完不跑,把图交给你审。
13.11.2 list_model_algorithms 的返回形状
不带 search 时,Whitebox 那半边太大没法序列化,于是按分组汇总:
{
"algorithms": [ "…全部矢量工具,带 ports 与 parameters…" ],
"rasterGroups": [ { "group": "Hydrology - Flow Routing", "tools": 22 } ],
"hint": "Raster/Whitebox tools are summarized by group. Call again with search …"
}
带 search 时返回匹配项,但最多 25 条(MAX_MODEL_ALGORITHM_MATCHES = 25),并给出 truncated: true。想看到被截掉的部分,只能把搜索词收窄。
13.11.3 create_model_builder_model 的入参
| 字段 | 类型 | 说明 |
|---|---|---|
name |
string | 模型名 |
inputs |
{ key, layer } 数组 |
layer 是图层 id 或图层名 |
steps |
{ key, algorithm, parameters?, inputs } 数组 |
algorithm 形如 vector:buffer |
outputs |
{ source, name } 数组 |
source 是步骤 key;多输出工具写 stepKey.portId |
steps[].inputs 是一个从输入端口 id 到上游 key 的映射。多输出工具必须带端口:"stepKey.portId"。
13.11.4 一次真实的对话
你:帮我把 roads 按 100 米做缓冲区,然后裁到 county 范围内,存成模型。
助手:先调 list_model_algorithms({ search: "buffer" }) → 得到 vector:buffer 的端口与参数
调 list_model_algorithms({ search: "clip" }) → 得到 vector:clip
create_model_builder_model({
name: "Roads buffer 100m clipped",
inputs: [ { key: "roads", layer: "roads" },
{ key: "county", layer: "county" } ],
steps: [ { key: "buf", algorithm: "vector:buffer",
parameters: { distance: 100, units: "meters", side: "outside" },
inputs: { layer: "roads" } },
{ key: "clip", algorithm: "vector:clip",
inputs: { layer: "buf", overlay: "county" } } ],
outputs: [ { source: "clip", name: "Roads buffer 100m (clipped)" } ]
})
调用链在内部是:buildAssistantModel(input, store().layers, descriptors) → store().saveModel(model) → setModelBuilderRequestedModelId(model.id) → setModelBuilderOpen(true)。模型一定被保存,但面板加载它之前会先问一句——画布上有未保存的改动、或有作业在跑时都会先确认。
警告:助手参数里出现的
vector:/whitebox:前缀和端口 id,必须来自list_model_algorithms的真实返回。让助手”凭印象”写参数是这类流程最常见的失败源——参数名错一个字母,节点在运行期就会报”缺少必填参数”。
13.12 实战
13.12.1 案例一:选址分析(矢量链,含分叉)
问题:找出”距主干道 500 m 以内、且不在基本农田内”的候选地块,并单独保留”距主干道 500 m 内”这一层供后续调参。
输入:parcels(面)、roads_primary(线)、farmland(面)。
图:
roads_primary ──▶ Buffer(500 m) ──┬──▶ Keep this result → 图层 A
│
parcels ──────────────────────────┼──▶ Clip(layer=buffer 结果, overlay=parcels)
│ │
farmland ─────────────────────────┴────────┴──▶ Difference(layer=clip 结果, overlay=farmland)
│
└──▶ Output:候选地块
步骤:
- + Input 三次,分别选三个图层。
- 拖入 Buffer(Geometry),
distance= 500,units=meters。 - 拖入 Clip(Overlay)。
layer接 Buffer 输出、overlay接parcels。 - 选中 Buffer 节点,点 Keep this result,得到图层 A(这一步会新增一个 Output 节点)。
- 拖入 Difference(Overlay)。
layer接 Clip 输出,overlay接farmland。 - 加 + Output,接 Difference,Result name 填
候选地块。 - Run。
需要提醒的两个参数坑:
- Buffer 的
dissolve若勾上,缓冲区会融成一个无属性要素,后续 Clip 出来的东西就全是无属性的。要按地块保留属性,必须让dissolve保持 false。 - Difference 的
overlay参数接的是要减掉的那一层,方向搞反会得到完全相反的结果,而且工具不会报错。
13.12.2 案例二:适宜性叠加(栅格链)
问题:把坡度、到道路距离、植被指数三个因子加权成一个 0–100 的适宜性面。
Model Builder 的调色板里没有 Spatial Statistics 的 Composite score(它不在 VECTOR_TOOLS 里,也不在 Whitebox 目录里),但 Whitebox 目录提供了等价能力的工具链:
dem ──▶ Slope(units=degrees) ──▶ Rescale Value Range(out_min=0, out_max=100,
clip_min=0, clip_max=30) ──┐
│
dist ──▶ Rescale Value Range(out_min=0, out_max=100) ──────────┤
├──▶ Weighted Overlay
ndvi ──▶ Rescale Value Range(out_min=0, out_max=100) ──────────┘ (weights=[0.4,0.35,0.25],
scale_max=100)
│
└──▶ Output
关键工具与其参数(whitebox-catalog-snapshot.json 原文):
| 工具 id | 显示名 | 主要参数 |
|---|---|---|
slope |
Slope | input、units(默认 degrees)、z_factor(默认 1) |
rescale_value_range |
Rescale Value Range | input、out_min、out_max、clip_min、clip_max |
reclass |
Reclass | input、reclass_values(list[list[float]])、assign_mode |
weighted_overlay |
Weighted Overlay | factors(list[Raster])、weights(list[float])、cost、constraints、scale_max |
weighted_sum |
Weighted Sum | input_rasters、weights |
注意:
weighted_overlay的factors在清单里 kind 是raster_in,因此在模型里是输入端口,靠连线喂栅格;weights(以及cost、constraints、scale_max)是面板参数,不上端口。也就是说,换因子要改连线、换权重只需改面板;但”一组因子与一组权重一一对应”的列表语义在模型里无法完整表达(同一个输入端口只能接一条边),这是当前实现的一个真实限制。
栅格链有一个前置条件必须自查:所有参与叠加的栅格必须同 CRS、同范围、同像元大小。Whitebox 的 overlay 系列不做重采样,尺寸不一致会直接失败。DEM 若是经纬度坐标,还要先确认 z_factor 该怎么设——见第 14 章的参数选择一节。
13.12.3 案例三:一套模型、多份输入(”批量裁剪”的正确做法)
很多人第一反应是”用 Model Builder 批量裁 200 个 Shapefile”。做不到——没有迭代器。正确的三种替代:
| 需求 | 该用的东西 | 原因 |
|---|---|---|
| 同一份裁剪掩膜,裁很多个输入图层 | Batch tools 的 Batch 模式 | 它就是为了”一个工具 × 多个输入”设计的 |
| 同一套多步流程,跑很多份数据 | 导出 Python 脚本,外面套 for 循环 |
控制流只有 Python 有 |
| 磁盘上一整个目录的文件 | Whitebox Toolbox 关掉 Run locally (WASM),走 sidecar | sidecar 能读原生文件路径,浏览器 WASM runner 不能 |
Model Builder 能提供的是第三种复用:把 Source layer 换掉,重跑同一张图。做法是:
- 保存模型。
- 在图层栈里把新数据加载进来。
- 回到 Model Builder,改每个 Input 的 Source layer。
- Run。
每次换输入都要手动改,这是”一套模型多份输入”的现状边界,别指望它记住历史。
13.13 与其他处理入口的衔接
| 从哪来 | 怎么进 Model Builder |
|---|---|
| Processing History 里一次成功的运行 | 记录里有 Copy JSON,可拿到工具 id 与参数,手工搬进节点 |
| Processing → Whitebox Toolbox 里调好的工具 | 同一个工具在调色板里就是 whitebox:<toolId>,参数名一一对应 |
| SQL Workspace 的查询结果图层 | 作为 Input 的 Source layer(前提是它已经是项目图层) |
| 插件生成图层 | 同上;只要进了图层栈,Input 就能选 |
反过来,Model Builder 的产出也是普通图层,可以继续喂给 Processing 对话框、导出 GeoParquet,或者被 SQL Workspace 查询。
13.14 本章小结
- Model Builder 把处理流程表达成有向无环图;入口是 Processing → Model Builder,面板浮在地图上,调色板 / 画布 / 设置 / 日志四块可拖拽。
- 只有三种节点:
input、tool、output。端口类型vector/raster/any,兼容规则是”任一侧为any或两侧相同”。 - 调色板是客户端矢量注册表(37 个)与 Whitebox 目录(本机快照 775 条,剔除 42 条
locked)的并集,key形如provider:toolId。 - 没有循环、条件、值节点,也没有撤销和复制粘贴。大改之前先 Export。
- 校验器实时给出十类问题,Run 在有任一问题时不可点。这十类问题的
code在model-graph.ts里是固定的字符串枚举。 - 只有接到 Output 的结果会保留;中间结果用 Keep this result / Keep “port” 显式接出去。
- 运行是拓扑序逐节点;失败即停,已产出的 Output 保留在图上;Cancel 在下一个节点启动前生效,正在跑的 Whitebox 工具会先跑完。
- 模型可以存进项目(
.geolibre.json),也可以导出成.model.json。导入有四道闸门:16 MB /$schema/ 2000 节点 / 4000 边;导入后必须重选每个 Input 的 Source layer。 - Copy Python script 只在成功跑过一次后可用,产出的是一行
m.run_model_builder(graph);默认timeout600 秒。 - AI 助手通过
list_model_algorithms+create_model_builder_model建图,建完只保存和打开,不执行。 - 真正的批量/循环不在 Model Builder 的能力范围内:Batch tools 负责”一个工具 × 多输入”,Python 负责控制流,sidecar 负责磁盘目录。
13.15 实践练习
-
最小模型:只用一个 Buffer 节点(不画 Input 节点),在它的 Input layer 参数里直接选一个点图层,做 250 米缓冲。解释为什么这样能跑——答案请落到 13.5.4 的”参数层兜底”机制上。
-
修复多处错误:故意做一个有问题的图——Input 不选图层、Output 不接线、两条边指向同一个输入口、再连一条边造成环。逐一记录校验器给出的提示文本,并对照 13.6 的表格确认
code与提示的对应关系。 -
中间结果对照:搭一个四节点链(Input → Buffer → Clip → Output),先只接一个 Output 跑一次,记下图层栈新增了几个图层;再用 Keep this result 把 Buffer 也接出去,重跑,对比图层数。
-
跨项目复用:把上一题导出的
.model.json导入到另一个项目,观察一打开时校验器报什么。然后手工把$schema改错、把文件撑到 17 MB,分别看导入是否被拒、提示是什么。 -
Python 往返:让模型成功跑一次,Copy Python script,把脚本粘到 Processing → Python Console 里执行,对比返回的图层 id 与地图上新增的图层。再把
timeout显式设成 60 秒跑一次栅格链,观察超时行为。 -
AI 建图与人工修正:用自然语言让 AI 助手生成一个”填洼 → 流向 → 流量累积 → 提取河网”的栅格模型,然后在 Model Builder 里逐节点检查:
extract_streams的threshold默认值是多少?端口名和你预期的一致吗?把不一致的地方改正后跑一次。
13.16 延伸阅读
- 处理工具总览与两个工具箱的区别:见 第12章 地理处理工具箱
- 用 SQL 做集合式分析:见 第11章 SQL 工作区与 DuckDB 空间分析
- Python 控制台与 Notebook 的用法:见 第19章 Python 控制台、Notebook 与 Jupyter 集成
- AI 助手的完整能力与 MCP:见 第18章 AI 助手、MCP 与智能体技能