|
| 1 | +--- |
| 2 | +name: "pipeline-generate" |
| 3 | +description: "自动生成 Pipeline OCR 文本节点。直接调 ocr() 拿目标文字 box,扩大 ROI 后合并到目标 pipeline 文件。提供单节点生成 (generate_node.py) + ROI 扫描找最佳 expand (generate_sweep.py) 两个脚本。" |
| 4 | +--- |
| 5 | + |
| 6 | +# pipeline-generate |
| 7 | + |
| 8 | +## 概念 |
| 9 | + |
| 10 | +Pipeline 由 Node 组成。本 skill 针对**OCR 文本识别节点**,按 Pipeline 协议生成节点 JSON 并合并到目标 pipeline 文件。 |
| 11 | + |
| 12 | +**核心流程**:连接设备 → `ocr()` 拿 box → 扩大 ROI → 合并节点 |
| 13 | + |
| 14 | +**自带脚本**(与本 SKILL.md 同目录): |
| 15 | + |
| 16 | +| 脚本 | 用途 | |
| 17 | +|------|------| |
| 18 | +| `generate_node.py` | 单节点生成(默认 `expand=20`) | |
| 19 | +| `generate_sweep.py` | 多 expand 变体扫描,找最佳 ROI | |
| 20 | + |
| 21 | +## MCP 工具绑定 |
| 22 | + |
| 23 | +依赖 `maa-mcp` MCP 服务。 |
| 24 | + |
| 25 | +| 工具 | 说明 | |
| 26 | +|------|------| |
| 27 | +| `find_adb_device_list` / `connect_adb_device` | 连接设备 | |
| 28 | +| `ocr` | **截图 + OCR 一步完成**(内部已调 screencap,外部不要再调) | |
| 29 | +| `load_pipeline` / `save_pipeline` | 读/写 pipeline JSON | |
| 30 | +| `check_and_download_ocr` | 首次需下载 OCR 模型 | |
| 31 | +| `run_pipeline` | 测试 pipeline 节点 | |
| 32 | + |
| 33 | +## 输入参数 |
| 34 | + |
| 35 | +| 参数 | 必填 | 默认 | 说明 | |
| 36 | +|------|------|------|------| |
| 37 | +| `target_text` | ✅ | — | 要识别的目标中文文字 | |
| 38 | +| `node_name` | ✅ | — | 节点名(PascalCase) | |
| 39 | +| `pipeline_file` | ✅ | — | 目标 pipeline 路径(相对 `assets/resource/base/pipeline/xxx.json` 或绝对路径) | |
| 40 | +| `action_type` | ❌ | `Click` | Click / DoNothing / LongPress / Swipe / ClickKey / InputText | |
| 41 | +| `expand_offset` | ❌ | `20` | ROI 扩边像素(**推荐先用 sweep 找最佳**) | |
| 42 | +| `post_delay` | ❌ | `500` | | |
| 43 | +| `timeout` | ❌ | `2000` | | |
| 44 | +| `overwrite` | ❌ | `False` | 节点名冲突时是否覆盖 | |
| 45 | + |
| 46 | +## 3 步工作流(伪代码) |
| 47 | + |
| 48 | +```python |
| 49 | +# === Step 1: 连接设备 === |
| 50 | +from maa_mcp.adb import find_adb_device_list, connect_adb_device |
| 51 | +controller_id = connect_adb_device(find_adb_device_list()[0]) |
| 52 | + |
| 53 | +# === Step 2: OCR 拿 box + 算 ROI === |
| 54 | +from maa_mcp.vision import ocr |
| 55 | +from maa_mcp.download import check_and_download_ocr |
| 56 | + |
| 57 | +ocr_results = ocr(controller_id) |
| 58 | +if isinstance(ocr_results, str) and "OCR 模型文件不存在" in ocr_results: |
| 59 | + check_and_download_ocr() |
| 60 | + ocr_results = ocr(controller_id) |
| 61 | + |
| 62 | +matched = [r for r in ocr_results if target_text in (r.text if hasattr(r, "text") else r["text"])] |
| 63 | +best = max(matched, key=lambda r: r.score if hasattr(r, "score") else r["score"]) |
| 64 | +box = best.box if hasattr(best, "box") else best["box"] |
| 65 | + |
| 66 | +# 扩大 ROI(720p 硬编码 + 4 边裁剪) |
| 67 | +SCREEN_W, SCREEN_H = 720, 1280 |
| 68 | +x, y, w, h = box |
| 69 | +E = expand_offset |
| 70 | +roi = [ |
| 71 | + max(0, x - E), |
| 72 | + max(0, y - E), |
| 73 | + min(SCREEN_W - max(0, x - E), w + 2 * E), |
| 74 | + min(SCREEN_H - max(0, y - E), h + 2 * E), |
| 75 | +] |
| 76 | + |
| 77 | +# === Step 3: 合并到目标 pipeline === |
| 78 | +from maa_mcp.pipeline_tools import load_pipeline, save_pipeline |
| 79 | +from pathlib import Path |
| 80 | + |
| 81 | +PROJECT_ROOT = Path(__file__).resolve().parents[3] |
| 82 | +pipeline_path = Path(pipeline_file) |
| 83 | +if not pipeline_path.is_absolute(): |
| 84 | + pipeline_path = PROJECT_ROOT / "assets" / "resource" / "base" / "pipeline" / pipeline_file |
| 85 | + |
| 86 | +existing = load_pipeline(str(pipeline_path)) or {} |
| 87 | +if node_name in existing and not overwrite: |
| 88 | + raise RuntimeError(f"节点 '{node_name}' 已存在") |
| 89 | +existing[node_name] = { |
| 90 | + "recognition": "OCR", |
| 91 | + "expected": [target_text], |
| 92 | + "roi": roi, |
| 93 | + "action": action_type, |
| 94 | + "post_delay": post_delay, |
| 95 | + "timeout": timeout, |
| 96 | +} |
| 97 | +save_pipeline( |
| 98 | + pipeline_json=json.dumps(existing, ensure_ascii=False, indent=4), |
| 99 | + output_path=str(pipeline_path), |
| 100 | + overwrite=True, |
| 101 | +) |
| 102 | +``` |
| 103 | + |
| 104 | +## ROI 扩大示意 |
| 105 | + |
| 106 | +``` |
| 107 | +原始 box: ┌────┐ |
| 108 | + │ 文字│ |
| 109 | + └────┘ |
| 110 | +扩大后 roi: ┌──────────┐ |
| 111 | + │ ┌────┐ │ |
| 112 | + │ │文字│ │ |
| 113 | + │ └────┘ │ |
| 114 | + └──────────┘ |
| 115 | +``` |
| 116 | + |
| 117 | +## 使用流程 |
| 118 | + |
| 119 | +> 脚本位于 `.claude/skills/pipeline-generate/`,所有命令从**项目根目录** `f:\workspace\MAAGC` 运行。 |
| 120 | +
|
| 121 | +### 步骤 1: Sweep 找最佳 expand |
| 122 | + |
| 123 | +```bash |
| 124 | +# 生成多个 expand 变体的测试 pipeline |
| 125 | +python .claude/skills/pipeline-generate/generate_sweep.py "角色" "46,1248,50,30" 0,5,10,15,20,25,30 |
| 126 | +``` |
| 127 | + |
| 128 | +然后用 `run_pipeline` 逐个测试每个 `Sweep_<text>_eN` 节点,**用 `BackButton_500ms` 返回大地图**(详见 [pipeline-testing](../pipeline-testing/SKILL.md))。记录成功的 expand 值(score ≥ 0.99 为佳)。 |
| 129 | + |
| 130 | +### 步骤 2: 正式生成节点 |
| 131 | + |
| 132 | +```bash |
| 133 | +python .claude/skills/pipeline-generate/generate_node.py "角色" UI_RoleListPage main_ui.json --expand 20 --overwrite |
| 134 | +``` |
| 135 | + |
| 136 | +## 关键经验 |
| 137 | + |
| 138 | +1. **`ocr()` 自动截图**:内部已调 `controller.post_screencap()`,**不要**再手动 screencap。证据:[vision.py:58](../MaaMCP/maa_mcp/vision.py#L58)。 |
| 139 | +2. **ROI 不是越大越好**:默认 `expand=75` 会失败(OCR 把"角色"拆成"电"+"色")。多数节点 sweet spot 是 `expand=20-30`。 |
| 140 | +3. **特殊节点需要小 ROI**:"城堡" expand≥20 全失败,**只接受 0-15**(上方有图标 M/3.9m/1077/👍 干扰)。 |
| 141 | +4. **`expected` 必须是中文**:`["角色"]` 正确,`["Role"]` 永远找不到。 |
| 142 | +5. **OCR 非确定性**:同一 ROI 不同次结果可能不同,`timeout: 2000` 期间会重试。 |
| 143 | +6. **OCR 失败不要换 TemplateMatch**:先用 sweep 找 sweet spot,多数情况能解决。 |
| 144 | +7. **可滚动 UI 用大 ROI + 父级 orchestrator**(**重要**): |
| 145 | + - **不要**在 Click 节点的 `next` 里放 `[JumpBack]CastleSwipeDown/Up` —— 找不到文字时会**死循环滑动**! |
| 146 | + - 正确模式参考 marry.json 里的 `CastleHall` 节点:父级 orchestrator 节点的 `next` 列表里放 `[JumpBack]XXXEntry` + `[JumpBack]XXXSwipeDown` + `[JumpBack]XXXSwipeUp` 等 |
| 147 | + - 滚动容错 ROI 范围参考 `CastleHallEntry`: `[60, 391, 609, 795]` |
| 148 | +8. **`run_pipeline` 必须有手动超时意识**:超过 ~10 秒不返回要主动停止,可能 ROI/expected 配错或 OCR 引擎卡住。 |
| 149 | +9. **改完 pipeline 文件后调 `load_pipeline(path)` 即可**:**不需要重启 server**。`run_pipeline` 每次都按 `pipeline_path` 从磁盘读最新内容,reload 后立即生效。 |
| 150 | +10. **可滚动 UI 用统一大 ROI**:当多个目标在同一个可滚动列表(如城堡建筑列表)时,**所有节点共用同一 ROI** `[x, top_y, w, full_h]`,覆盖整个滚动区域。避免每个节点各自 ROI 滚动后失效。前提:每个节点的 `expected` 文字是唯一的(OCR 按 expected 匹配不会冲突)。 |
| 151 | +11. **ROI 上边界 ≤ 元素最小 y**:目标元素在 y=424 时,ROI y 起点必须 ≤ 424,否则切掉顶部导致 OCR 失败。例:原 ROI `[100, 450, ...]` 把"城堡管理"切掉 26px → 改为 `[100, 400, ...]` 通过。 |
| 152 | +12. **卡住时截图查看**:节点超时、OCR 找不到、行为异常时,调 `screencap` 看当前屏幕实际状态。可能界面已不在预期页、可能位置已被遮挡。 |
| 153 | + |
| 154 | +13. **跨页面流程用 `next` 状态机而非 Python orchestration**:当一个流程涉及多个页面跳转(如:大地图 → 活动入口 → 难度选择 → 队伍 → 战斗),用 MaaFramework 的 `next` + `[JumpBack]` 串节点。**不要**写 Python `for/while` 调 `context.run_task()` 模拟状态机。详见 [.claude/skills/pipeline-option/SKILL.md](../pipeline-option/SKILL.md) 的「不要做 #10」和 [.claude/skills/pipeline-guide/SKILL.md](../pipeline-guide/SKILL.md) 的「跨页面状态机」。 |
| 155 | + |
| 156 | +14. **跨文件节点引用在 `run_pipeline` 测试中会失败**:MaaFramework 全局加载时所有 `assets/resource/base/pipeline/*.json` 合并到同一命名空间,`[JumpBack]OtherFileNode` 能解析。但 `run_pipeline` **只加载单文件**,跨文件引用会报"加载 Pipeline 失败"。**应对**: |
| 157 | + - 单元测试每个节点用 `run_pipeline`(无跨文件依赖的子流程)是 OK 的 |
| 158 | + - 含跨文件引用的状态机流程,集成测试必须用 MaaFramework GUI/CLI 触发 |
| 159 | + - 调试时可考虑 `MaaCli` 命令行运行全 bundle |
| 160 | + |
| 161 | +### 已验证最优 expand(5 节点实测) |
| 162 | + |
| 163 | +| 节点 | expand | score | 备注 | |
| 164 | +|------|--------|-------|------| |
| 165 | +| `UI_RoleListPage` | **20** | 0.9997 | 中部偏左 | |
| 166 | +| `UI_RoleFormationPage` | **20** | 0.998 | 角色右边 | |
| 167 | +| `UI_CastlePage` | **3** | 0.997 | ⚠️ 仅 0-15 | |
| 168 | +| `UI_TeamPage` | **20** | 0.997 | 城堡右边 | |
| 169 | +| `ClickGoToArchipelago` | **20** | 0.991 | 中间大地图按钮 | |
| 170 | + |
| 171 | +### 已验证:可滚动 UI 统一 ROI(10 城堡建筑) |
| 172 | + |
| 173 | +| 节点 | 统一 ROI | score | 备注 | |
| 174 | +|------|----------|-------|------| |
| 175 | +| `CastleManage` | `[100, 400, 520, 880]` | 0.999 | 顶部 | |
| 176 | +| `Market` | 同上 | 0.999 | 顶部 | |
| 177 | +| `Blacksmith` | 同上 | 0.998 | 顶部 | |
| 178 | +| `AlchemyWorkshop` | 同上 | 0.999 | 顶部 | |
| 179 | +| `TrainingCenter` | 同上 | 1.000 | 顶部 | |
| 180 | +| `CastleMainHall` | 同上 | 0.876 | 顶部只露 25px | |
| 181 | +| `Shrine` | 同上 | 0.999 | 中段 | |
| 182 | +| `Family` | 同上 | 0.999 | 中段 | |
| 183 | +| `Museum` | 同上 | 0.999 | 底部 | |
| 184 | +| `Manor` | 同上 | 0.999 | 底部 | |
| 185 | + |
| 186 | +**关键设计**: |
| 187 | +- 所有节点 ROI 完全相同(`[100, 400, 520, 880]`,覆盖 y=400-1280) |
| 188 | +- 不靠 expand 微调,靠 `expected` 文字差异让 OCR 区分 |
| 189 | +- 不放 `next` 链(避免死循环) |
| 190 | + |
| 191 | +--- |
| 192 | + |
| 193 | +## 跨页面状态机流程(用 `next` + `[JumpBack]`) |
| 194 | + |
| 195 | +当生成的活动流程需要**跨多个页面跳转**(如:大地图 → 活动入口 → 难度选择 → 队伍 → 战斗),用 MaaFramework 的 `next` + `[JumpBack]` 机制串接各页面节点,**不要写 Python orchestration**。 |
| 196 | + |
| 197 | +### 模式:状态机入口节点 |
| 198 | + |
| 199 | +```jsonc |
| 200 | +{ |
| 201 | + "MyActivity_Start": { |
| 202 | + "next": [ |
| 203 | + "MyActivity_TeamReady", // 已在队伍配置页 → 点击"进入战斗" |
| 204 | + "[JumpBack]MyActivity_Difficulty_Select", // 在难度选择页 → 选难度 |
| 205 | + "[JumpBack]MyActivity_Enter" // 在大地图 → 找入口 |
| 206 | + ], |
| 207 | + "timeout": 10000 |
| 208 | + }, |
| 209 | + |
| 210 | + "MyActivity_Enter": { |
| 211 | + "next": [ |
| 212 | + "MyActivity_Enter_Click", // 找到图标 → 点击 |
| 213 | + "[JumpBack]BigMap_Activity_Resident", // 切"常驻"tab |
| 214 | + "[JumpBack]BigMap_Activity" // 打开活动页 |
| 215 | + ], |
| 216 | + "timeout": 10000 |
| 217 | + }, |
| 218 | + |
| 219 | + "MyActivity_EnterBattle": { |
| 220 | + "recognition": "OCR", |
| 221 | + "expected": ["进入战斗"], |
| 222 | + "action": "Click", |
| 223 | + "next": [ |
| 224 | + "MyActivity_FightStart", // 战斗开始 |
| 225 | + "[JumpBack]MyActivity_TravelSelect_Boat", // 乘船 |
| 226 | + "[JumpBack]MyActivity_TravelSelect_Walk" // 步行 fallback |
| 227 | + ] |
| 228 | + }, |
| 229 | + |
| 230 | + "MyActivity_TravelSelect_Boat": { |
| 231 | + "recognition": "OCR", |
| 232 | + "expected": ["确定"], |
| 233 | + "roi": [490, 740, 100, 80], // 窄 ROI 限定乘船行 |
| 234 | + "action": "Click" |
| 235 | + }, |
| 236 | + |
| 237 | + "MyActivity_TravelSelect_Walk": { |
| 238 | + "recognition": "OCR", |
| 239 | + "expected": ["确定"], |
| 240 | + "roi": [490, 590, 100, 80], // 窄 ROI 限定步行行 |
| 241 | + "action": "Click" |
| 242 | + } |
| 243 | +} |
| 244 | +``` |
| 245 | + |
| 246 | +### 关键设计要点 |
| 247 | + |
| 248 | +1. **`[JumpBack]` 是状态回退的关键**:命中后执行完节点链,自动返回父节点的 `next` 继续。 |
| 249 | +2. **窄 ROI 区分同名字段**:用 y 范围 [490, 740, 100, 80] vs [490, 590, 100, 80] 区分两个"确定"按钮行(y 范围不重叠)。 |
| 250 | +3. **`target_offset` 偏移点击**:识别难度文字后用 `target_offset: [270, 0, 0, 0]` 把点击位置右移到"确定"按钮上。 |
| 251 | +4. **跨文件节点引用**:MaaFramework 全局加载会合并所有 `pipeline/*.json`,所以 `[JumpBack]BigMap_Activity`(在 main_ui.json)能从 growth_trial.json 引用。但 `run_pipeline` 测试只加载单文件,集成测试需用 GUI/CLI。 |
| 252 | + |
| 253 | +### 与 Python orchestration 的本质区别 |
| 254 | + |
| 255 | +| 状态机(推荐) | Python orchestration(次选) | |
| 256 | +|--------------|--------------------------| |
| 257 | +| 流程推进由 MaaFramework 调度 | 自己写 `for/if` 调度 | |
| 258 | +| 每个节点 `next` 显式声明后继 | Python 函数串行 `run_task` | |
| 259 | +| `[JumpBack]` 自动状态回退 | 手动实现回退逻辑 | |
| 260 | +| 跨页面异常有自然路径 | 需手动 try/except | |
| 261 | + |
| 262 | +详见 [.claude/skills/pipeline-option/SKILL.md](../pipeline-option/SKILL.md) 的「不要做 #10」和 [.claude/skills/pipeline-guide/SKILL.md](../pipeline-guide/SKILL.md) 的「跨页面状态机」典型模式。 |
0 commit comments