第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 画布本身的两个反直觉点

  1. 画布只平移,不缩放。 拖空白处滚动视口;没有缩放手势。节点多了就靠 Arrange 重排。
  2. 节点卡片可以拖着摆位置,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;
}

三个”会被静默丢掉”的分支,值得记住:

  1. locked 为真的 Whitebox 工具被剔除。它们跑不了,放上画布只会做出一个在最后一步失败的模型。本机 whitebox-catalog-snapshot.json 快照共 775 条工具,其中 42 条带 locked: true。
  2. 没有输出端口的工具返回 null 被跳过——下游没有东西能消费它。
  3. 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 步:准备输入图层

  1. 确认项目里已有两个矢量图层:roads(LineString)与 study_area(Polygon)。
  2. 打开 Processing → Model Builder。
  3. 点 + Input,在新节点右侧设置面板的 Source layer 里选 roads。

13.4.2 第 2 步:拖入 Buffer 工具

  1. 在 Search tools 里输入 buffer。
  2. 按住结果里的 Buffer(分组 Geometry,provider 显示为矢量注册表)拖到画布。
  3. 也可以单击它,或者选中后按 Enter,工具会加在默认位置。

13.4.3 第 3 步:连线

  1. 从 Input 节点右侧的输出口 out 按下鼠标,拖到 Buffer 节点左侧的输入口。
  2. 松手。连线出现。
  3. 连错了:点那条曲线的任意位置即可删除。
  4. 键盘操作:先激活输出口把它”上膛”,再激活输入口完成连接。

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 步:接第二个工具做裁剪

  1. 再加一个 + Input,Source layer 选 study_area。
  2. 搜索 clip,把 Clip(分组 Overlay)拖到画布。
  3. Buffer 的 out → Clip 的 layer(Input layer)。
  4. 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 并命名

  1. 点 + Output。
  2. Clip 的 out → Output 的 in。
  3. Result name 填 Roads buffer 100m (clipped)。

13.4.7 第 7 步:运行与查看

  1. 确认 Run 已经可点(不可点说明校验器还有问题,去对应节点的设置面板看红字)。
  2. 点 Run。节点卡片依次变成 running → done。
  3. 日志里应出现三行工具日志加若干 Running …。
  4. 图层栈里多出一个 Roads buffer 100m (clipped) 的 GeoJSON 图层。

13.4.8 第 8 步:保存与导出

  1. Model name 填 Roads buffer。
  2. Save——存进项目。
  3. 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 的拒绝条件写得很严格,逐条列出来是因为它解释了”为什么我的模型在老版本里跑不了”:

  1. 有环 → 返回空数组。
  2. 不是恰好一个 input 节点、恰好一个 output 节点 → 返回空数组。
  3. input 节点的出度不等于 1 → 返回空数组。
  4. output 节点的入度不等于 1 → 返回空数组。
  5. 任一 tool 节点入度或出度不为 1 → 返回空数组。
  6. 任一 tool 节点的 provider 不是 vector,或没有 toolId → 返回空数组。

第 3、4 条的存在理由在注释里说得很清楚:一条分支如果穿过共享的 input 或 output 节点,每个工具节点的入度/出度仍然是 1,逐节点检查会放它过关,然后 runModel 会把两条分支当成一条串行链跑——正是这个函数存在要拒绝的”静默截断”。

返回空数组是”诚实的答案”:这种模型没有线性等价物,老版本不应该跑一个被截断的版本。

13.8 运行、取消与失败处理

13.8.1 执行模型

runModelGraph 按拓扑序逐个执行节点。执行一个节点时:

  1. 从入边收集上游值,键是目标端口 id。
  2. 若是 input:解析 layerId;解不出就报 Input layer "<id>" has no usable data. 并整体中止。
  3. 若是 output:取 in 端口的值;没有就报 Output node has nothing connected to it.;有就 emitOutput(name, value, node) 并记进 outputs。
  4. 若是 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": [...]})

要点:

  1. m 是一个已显示的 geolibre.Map——在 notebook 里或 Processing → Python Console 里。模型的输入图层必须已经加载到那个 map 上。
  2. 整张图被原样序列化进调用,不是翻译成一个个 m.buffer(...) 调用。也就是说 Python 侧拿到的是同一张图,由应用校验并执行。
  3. 返回的是输出图层的 id 列表。
  4. 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:候选地块

步骤:

  1. + Input 三次,分别选三个图层。
  2. 拖入 Buffer(Geometry),distance = 500,units = meters。
  3. 拖入 Clip(Overlay)。layer 接 Buffer 输出、overlay 接 parcels。
  4. 选中 Buffer 节点,点 Keep this result,得到图层 A(这一步会新增一个 Output 节点)。
  5. 拖入 Difference(Overlay)。layer 接 Clip 输出,overlay 接 farmland。
  6. 加 + Output,接 Difference,Result name 填 候选地块。
  7. 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 换掉,重跑同一张图。做法是:

  1. 保存模型。
  2. 在图层栈里把新数据加载进来。
  3. 回到 Model Builder,改每个 Input 的 Source layer。
  4. 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);默认 timeout 600 秒。
  • AI 助手通过 list_model_algorithms + create_model_builder_model 建图,建完只保存和打开,不执行。
  • 真正的批量/循环不在 Model Builder 的能力范围内:Batch tools 负责”一个工具 × 多输入”,Python 负责控制流,sidecar 负责磁盘目录。

13.15 实践练习

  1. 最小模型:只用一个 Buffer 节点(不画 Input 节点),在它的 Input layer 参数里直接选一个点图层,做 250 米缓冲。解释为什么这样能跑——答案请落到 13.5.4 的”参数层兜底”机制上。

  2. 修复多处错误:故意做一个有问题的图——Input 不选图层、Output 不接线、两条边指向同一个输入口、再连一条边造成环。逐一记录校验器给出的提示文本,并对照 13.6 的表格确认 code 与提示的对应关系。

  3. 中间结果对照:搭一个四节点链(Input → Buffer → Clip → Output),先只接一个 Output 跑一次,记下图层栈新增了几个图层;再用 Keep this result 把 Buffer 也接出去,重跑,对比图层数。

  4. 跨项目复用:把上一题导出的 .model.json 导入到另一个项目,观察一打开时校验器报什么。然后手工把 $schema 改错、把文件撑到 17 MB,分别看导入是否被拒、提示是什么。

  5. Python 往返:让模型成功跑一次,Copy Python script,把脚本粘到 Processing → Python Console 里执行,对比返回的图层 id 与地图上新增的图层。再把 timeout 显式设成 60 秒跑一次栅格链,观察超时行为。

  6. AI 建图与人工修正:用自然语言让 AI 助手生成一个”填洼 → 流向 → 流量累积 → 提取河网”的栅格模型,然后在 Model Builder 里逐节点检查:extract_streams 的 threshold 默认值是多少?端口名和你预期的一致吗?把不一致的地方改正后跑一次。

13.16 延伸阅读