commit c284eb8c7db477daca7abe4aea2a5766e2424199 Author: lichx <751176501@qq.com> Date: Fri Jun 12 10:28:29 2026 +0800 :sparkles: feat: workable diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..945c6db --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# 预览图(标定生成,含关号,如 preview_level1.png) +*level*.png + +# 标定导出的格子小图 +cells/ + +# Python +__pycache__/ +*.py[cod] +.pytest_cache/ +*.egg-info/ +.venv/ +src-images/ +debug + +templates/**/*.png diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..a8c2003 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "python-envs.defaultEnvManager": "ms-python.python:conda", + "python-envs.defaultPackageManager": "ms-python.python:conda", + "python-envs.pythonProjects": [] +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..3652eb1 --- /dev/null +++ b/README.md @@ -0,0 +1,151 @@ +# happy-birds-cracker + +基于 [MaaFramework](https://github.com/MaaXYZ/MaaFramework) 的**滑动三消(消消乐)自动求解器**。 + +MaaFramework 负责 **截屏 + 图像识别 + ADB 滑动控制**;本项目负责把画面变成棋盘模型、 +计算最优一步、再把交换映射回屏幕滑动。 + +> ⚠️ 仅供学习与离线/单机研究。请遵守目标游戏的服务条款,线上对战/带反作弊的游戏请勿使用。 + +## 设计要点 + +- **网格动态可变,恒为矩形**:尺寸由 `GridConfig(rows, cols)` 决定,换关卡只需改行列数 + (或在识别阶段探测后填入),核心算法与尺寸无关。 +- **三种块类型**(`CellKind`,为后续编程预留 `meta` 扩展字段): + - `NORMAL` 正常块:可交换,带 `color`。 + - `BRICK` 砖块:不可移动的障碍,会打断连线。 + - `EMPTY` 空块/空洞:无方块,会打断连线。 + +## 目录结构 + +``` +src/hbc/ + board.py 棋盘数据模型:Cell / CellKind / Board(动态矩形) + solver.py 三消求解:找匹配 + 遍历相邻交换搜索最优解 + config.py GridConfig(像素<->格子映射,可存 JSON)/ 模板比对参数 + state.py GameState:识别与动作之间共享 Board / 当前关卡 + templates.py 模板图像库:子图比对识别,支持多种墙(推荐识别方式) + recognizer.py 截图 -> Board(可插拔分类器:模板/颜色 + MaaFw 识别桩) + level.py 关卡识别:OCR 标题"关卡一" -> 关卡号 -> 载入对应布局 + calibrate.py 标定工具:框选棋盘生成布局、预览、导出格子图(窗口自适应屏幕) + actuator.py Swap -> ADB 滑动(纯函数 + MaaFw 自定义动作 SwapAction) + main.py 连接设备、加载资源、注册自定义模块、循环运行(支持 --dry-run) +resource/ + pipeline/ + pipeline.json MaaFramework 低代码流程:识别棋盘 -> 执行交换 -> 循环 +templates/ 模板库(你标定后放入):normal/ wall/ empty/ +layouts/ 各关布局:level1.json level2.json ...(你标定后生成) +tests/ + test_solver.py 求解器与模型测试 + test_recognition.py 模板识别 + 标定 + 多关卡布局测试(合成图,无需真机) + test_level.py 关卡号 OCR 文本解析测试 +``` + +## 如何识别棋盘 + +本游戏网格逐关变大且含空格。布局**按关卡分文件**存在 `layouts/` 文件夹下 +(`layouts/level1.json`、`layouts/level2.json` ...),每关只需标定一次。 +标定窗口会**自动缩放适配屏幕**(截图比屏幕大也能完整框选)。 + +```bash +# 1) 框出棋盘 + 填该关行列数 -> 写入 layouts/level1.json(弹窗自动缩放,鼠标拖框回车确认) +python -m hbc.calibrate roi --image src-images/level1.jpg --level 1 --rows 5 --cols 5 +# 窗口仍太大可调上限:--max-display 700 +# 无 GUI / 想直接给坐标:--box x,y,w,h(按原图像素) + +# 2) 叠加网格预览,确认每个格子对齐(绿框=棋盘,橙线=网格,红点=格子中心) +python -m hbc.calibrate preview --image src-images/level1.jpg --level 1 --out preview_level1.png + +# 3) 把每个格子切成小图,导出到 cells/,再人工分类到模板库 +python -m hbc.calibrate export --image src-images/level1.jpg --level 1 --out cells/ + +# 后续关卡同理,换 --level 2/3/... 和对应行列数即可 +python -m hbc.calibrate roi --image src-images/level2.jpg --level 2 --rows 6 --cols 6 +``` + +运行时用 `--level` 选择关卡:`python -m hbc.main --level 1`。 + +### 对齐技巧(棋盘四周有 padding) + +均匀切分基于你框的矩形,所以框要贴着**棋子区域**而不是外层面板: +从**左上角棋子的左上角**拖到**右下角棋子的右下角**(把面板留白排除在外), +再用 `preview` 看红点是否落在每个棋子中心,不对就重框。反复"框 → 预览"几次即可对齐。 + +### 运行时自动识别当前是第几关(OCR) + +游戏顶部写着"关卡一",MaaFramework **自带 OCR**(PaddleOCR 的 ONNX 模型),运行时 +可直接识别,**不用你预先截图**。流程:标定一次标题区域 ROI -> `LevelRecognition` +(`level.py`)OCR 标题 -> `parse_cn_level` 解析成关卡号 -> 自动载入 `layouts/level{N}.json`。 +若你的 MaaFw 资源里没带 OCR 模型,去掉该节点、改用 `--level` 手动指定即可。 + +把导出的小图按类别放进模板库(文件名即类别): + +``` +templates/ + normal/red.png green.png blue.png ... 每种正常块一张(文件名->稳定颜色 id) + wall/stone.png ice.png chain.png ... 墙可以有多种,每种一张 + empty/hole.png 空块/空洞 +``` + +### 为什么用模板比对而不是纯颜色 + +MaaFramework 的颜色识别对光照/特效/相近色容易误判。本项目识别走**子图比对**: +把每个格子缩放后与模板逐像素比相似度,取最高分。相似度对**颜色和形状都敏感** +(不像 `TM_CCOEFF_NORMED` 会忽略颜色),更适合消消乐。仍保留 `ColorClassifier` 作为兜底。 + +### 冰块 = 低置信度即判为墙 + +本游戏除正常生物/空格外只有一种特殊方块——**冰块**(冰下隐约有正常图案)。冰下图案 +模糊,模板匹配置信度天然偏低,因此采用一条简单规则:**凡是匹配不够自信的格子, +一律判为冰墙**(`cell.meta['wall']='ice'`,不可交换、打断连线)。 + +冰墙不会被直接交换,但当它旁边发生消除时,游戏里冰会化掉、露出正常生物,下一帧重新 +识别就变回可用的普通块,于是自然推进。这是有意的取舍:牺牲对"未登记新方块"的健壮性, +换取对冰块的零配置支持。阈值在 `TemplateMatchConfig.score_threshold` / +`empty_min_score`,需要时可调。 + +### 新增正常生物 + +后续关卡出现新动物时,把它的一张干净小图放进 `templates/normal/<名字>.png` 即可 +(每个不同文件名 = 一个独立可消除类型;如灰色魟鱼 `stingray.png` 与青色水母 +`jellyfish.png` 是两种)。用 `hbc.calibrate export` 可批量导出格子图来挑样本。 + +### 多种墙(可选) + +若以后遇到别的固定障碍,可在 `templates/wall/` 放多张墙图(石墙、锁链……), +它们都识别为 `CellKind.BRICK`,种类记在 `cell.meta['wall']`,便于针对性写逻辑。 + +## 数据流 + +``` +MaaFramework 截屏 + -> BoardRecognition(recognizer): 按网格切格、判颜色/砖块/空块 -> Board + -> SwapAction(actuator): Solver.find_best_swap(board) -> 最优 Swap + -> 通过控制器 post_swipe 滑动 + -> 循环 +``` + +求解与控制是解耦的:`board.py` + `solver.py` 完全不依赖 MaaFramework / 真机,可独立测试。 + +## 快速开始 + +```bash +# 安装为可编辑包:之后在项目根目录任何位置都能用 hbc,无需设置 PYTHONPATH 或 cd src +pip install -e . + +# 离线验证求解器(不需要设备) +python -m pytest tests/ -q + +# 连接设备后运行(需先标定 config 中的 ROI 与颜色,见下) +python -m hbc.main # 自动探测第一个 adb 设备 +python -m hbc.main 127.0.0.1:16384 # 或指定模拟器地址 +python -m hbc.main --level 2 # 求解第二关 +``` + +## 预留的扩展点 + +- `Cell.meta`:放特殊方块(条状/炸弹/彩球)、墙的种类 `wall`、砖块血量、冰冻层数等。 +- `Solver` 的 `scorer`:自定义打分策略(默认按消除格子数,4/5 连额外加权)。 +- `Solver._cells_cleared_by`:补充"相邻砖块被消除破坏"等连锁规则。 +- `GridConfig.rows/cols`:动态调整网格尺寸(每关不同时重新标定/探测)。 +- `recognizer` 的分类器可插拔:默认模板库,必要时换 `ColorClassifier` 兜底。 diff --git a/config/maa_option.json b/config/maa_option.json new file mode 100644 index 0000000..5de39ef --- /dev/null +++ b/config/maa_option.json @@ -0,0 +1,7 @@ +{ + "draw_quality": 85, + "logging": true, + "save_draw": false, + "save_on_error": true, + "stdout_level": 2 +} \ No newline at end of file diff --git a/crop_cell.py b/crop_cell.py new file mode 100644 index 0000000..254f088 --- /dev/null +++ b/crop_cell.py @@ -0,0 +1,10 @@ +import sys +import cv2 +from hbc.config import GridConfig + +image_path, level, r, c, out = sys.argv[1], int(sys.argv[2]), int(sys.argv[3]), int(sys.argv[4]), sys.argv[5] +img = cv2.imread(image_path) +grid = GridConfig.load_json(f"layouts/level{level}.json").for_image(img.shape[1], img.shape[0]) +x, y, w, h = grid.cell_box(r, c) +cv2.imwrite(out, img[y:y + h, x:x + w]) +print("saved", out, "from cell", (r, c), "box", (x, y, w, h)) diff --git a/diag.py b/diag.py new file mode 100644 index 0000000..656fb29 --- /dev/null +++ b/diag.py @@ -0,0 +1,24 @@ +import sys +import cv2 +from hbc.config import GridConfig, TemplateMatchConfig +from hbc.templates import TemplateLibrary +from hbc.recognizer import recognize_board + +image_path = sys.argv[1] +level = int(sys.argv[2]) +img = cv2.imread(image_path) +grid = GridConfig.load_json(f"layouts/level{level}.json") +lib = TemplateLibrary.load(TemplateMatchConfig(templates_dir="templates")) +board = recognize_board(img, grid, lib) +for r in range(board.rows): + row = [] + for c in range(board.cols): + cell = board.get(r, c) + if cell.is_brick: + lab = "ICE" + elif cell.is_empty: + lab = "." + else: + lab = cell.meta.get("label", "?") + row.append(f"{lab[:6]:>6}:{cell.meta.get('score', '-')}") + print(" ".join(row)) diff --git a/layouts/level1.json b/layouts/level1.json new file mode 100644 index 0000000..99a4f6a --- /dev/null +++ b/layouts/level1.json @@ -0,0 +1,10 @@ +{ + "x": 40, + "y": 949, + "w": 984, + "h": 981, + "rows": 5, + "cols": 5, + "ref_w": 1080, + "ref_h": 2400 +} diff --git a/layouts/level2.json b/layouts/level2.json new file mode 100644 index 0000000..b24c855 --- /dev/null +++ b/layouts/level2.json @@ -0,0 +1,10 @@ +{ + "x": 40, + "y": 941, + "w": 995, + "h": 995, + "rows": 6, + "cols": 6, + "ref_w": 1080, + "ref_h": 2400 +} \ No newline at end of file diff --git a/layouts/level3.json b/layouts/level3.json new file mode 100644 index 0000000..2dbf52f --- /dev/null +++ b/layouts/level3.json @@ -0,0 +1,10 @@ +{ + "x": 40, + "y": 944, + "w": 997, + "h": 989, + "rows": 7, + "cols": 7, + "ref_w": 1080, + "ref_h": 2400 +} \ No newline at end of file diff --git a/layouts/level4.json b/layouts/level4.json new file mode 100644 index 0000000..7dfb919 --- /dev/null +++ b/layouts/level4.json @@ -0,0 +1,10 @@ +{ + "x": 37, + "y": 939, + "w": 997, + "h": 1000, + "rows": 7, + "cols": 7, + "ref_w": 1080, + "ref_h": 2400 +} \ No newline at end of file diff --git a/layouts/level5.json b/layouts/level5.json new file mode 100644 index 0000000..8e5827c --- /dev/null +++ b/layouts/level5.json @@ -0,0 +1,10 @@ +{ + "x": 40, + "y": 941, + "w": 987, + "h": 989, + "rows": 8, + "cols": 8, + "ref_w": 1080, + "ref_h": 2400 +} \ No newline at end of file diff --git a/layouts/level6.json b/layouts/level6.json new file mode 100644 index 0000000..dc52fe0 --- /dev/null +++ b/layouts/level6.json @@ -0,0 +1,10 @@ +{ + "x": 43, + "y": 944, + "w": 984, + "h": 987, + "rows": 8, + "cols": 8, + "ref_w": 1080, + "ref_h": 2400 +} \ No newline at end of file diff --git a/layouts/level7.json b/layouts/level7.json new file mode 100644 index 0000000..f362930 --- /dev/null +++ b/layouts/level7.json @@ -0,0 +1,10 @@ +{ + "x": 40, + "y": 941, + "w": 992, + "h": 989, + "rows": 8, + "cols": 8, + "ref_w": 1080, + "ref_h": 2400 +} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..df4b41b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,16 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "happy-birds-cracker" +version = "0.1.0" +description = "基于 MaaFramework 的滑动三消(消消乐)自动求解器" +requires-python = ">=3.10" +dependencies = [ + "numpy>=1.24", + "opencv-python>=4.8", +] + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e9b34db --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +# MaaFramework Python 绑定(截屏 + 图像识别 + ADB 控制) +MaaFw>=4.0.0 + +# 图像处理(自定义识别里切割网格、颜色判断时使用) +numpy>=1.24 +opencv-python>=4.8 diff --git a/resource/pipeline/pipeline.json b/resource/pipeline/pipeline.json new file mode 100644 index 0000000..778ad78 --- /dev/null +++ b/resource/pipeline/pipeline.json @@ -0,0 +1,12 @@ +{ + "解三消": { + "recognition": "Custom", + "custom_recognition": "BoardRecognition", + "action": "Custom", + "custom_action": "SwapAction", + "next": [ + "解三消" + ], + "post_delay": 600 + } +} diff --git a/src/hbc/__init__.py b/src/hbc/__init__.py new file mode 100644 index 0000000..f6549b1 --- /dev/null +++ b/src/hbc/__init__.py @@ -0,0 +1,27 @@ +"""happy-birds-cracker + +基于 MaaFramework 的滑动三消(消消乐)自动求解器。 + +模块划分: +- board: 棋盘数据模型(动态矩形网格 + 块类型) +- solver: 三消求解算法(找匹配 + 搜索最优交换) +- recognizer: MaaFramework 自定义识别(截屏 -> Board) +- actuator: MaaFramework 自定义动作(Swap -> ADB 滑动) +- config: 网格与识别配置 +""" + +from .board import Board, Cell, CellKind +from .config import GridConfig, RecognizeConfig, TemplateMatchConfig +from .solver import Match, Solver, Swap + +__all__ = [ + "Board", + "Cell", + "CellKind", + "GridConfig", + "Match", + "RecognizeConfig", + "Solver", + "Swap", + "TemplateMatchConfig", +] diff --git a/src/hbc/actuator.py b/src/hbc/actuator.py new file mode 100644 index 0000000..1d833b5 --- /dev/null +++ b/src/hbc/actuator.py @@ -0,0 +1,70 @@ +"""动作:把求解器算出的 Swap 转成屏幕滑动(ADB swipe)。 + +两层结构: +1. 纯函数 `swap_to_swipe(swap, grid)` —— 把交换转成 (x1,y1,x2,y2),便于离线测试。 +2. `SwapAction` —— MaaFramework 自定义动作适配器(仅在装了 MaaFw 时可用)。 +""" + +from __future__ import annotations + +from .config import GridConfig +from .solver import Solver, Swap +from .state import GameState + + +def swap_to_swipe(swap: Swap, grid: GridConfig) -> tuple[int, int, int, int]: + """把一次交换映射为屏幕滑动起止像素坐标。 + + 返回 (x1, y1, x2, y2):从 (x1,y1) 滑到 (x2,y2)。 + + 重要:**水平交换一律从右往左滑**(起点在右、终点在左)。因为本游戏里 + 从左往右滑会触发返回/退出手势。交换两格的结果与滑动方向无关,所以反向滑同样有效。 + 上下滑动不受影响。 + """ + p1 = grid.cell_center(swap.r1, swap.c1) + p2 = grid.cell_center(swap.r2, swap.c2) + if swap.r1 == swap.r2 and p1[0] < p2[0]: + # 水平交换且当前是左->右,交换起止,强制右->左 + p1, p2 = p2, p1 + return p1[0], p1[1], p2[0], p2[1] + + +# ---------------------------------------------------------------------------# +# MaaFramework 自定义动作适配器 +# ---------------------------------------------------------------------------# +try: + from maa.custom_action import CustomAction # type: ignore + + class SwapAction(CustomAction): + """读取共享 Board -> 求最优交换 -> 通过控制器滑动。 + + 在 pipeline 中以 Custom 动作引用本类。无可行交换时返回 False(pipeline 可据此 + 判定本局结束或刷新棋盘)。 + """ + + def __init__(self, state: GameState, solver: Solver | None = None, + swipe_duration_ms: int = 200) -> None: + super().__init__() + self.state = state + self.solver = solver or Solver() + self.swipe_duration_ms = swipe_duration_ms + + def run(self, context, argv) -> bool: + board = self.state.board + if board is None: + return False + + result = self.solver.find_best_swap(board) + if result is None: + # 没有可行消除:交给 pipeline 处理(如洗牌/重识别) + return False + + grid = self.state.grid_runtime or self.state.grid + x1, y1, x2, y2 = swap_to_swipe(result.swap, grid) + # MaaFw 控制器滑动;不同版本 API 名称可能为 post_swipe / swipe + controller = context.tasker.controller + controller.post_swipe(x1, y1, x2, y2, self.swipe_duration_ms).wait() + return True + +except Exception: # pragma: no cover - 未安装 MaaFw 时跳过 + SwapAction = None # type: ignore diff --git a/src/hbc/board.py b/src/hbc/board.py new file mode 100644 index 0000000..97f2db5 --- /dev/null +++ b/src/hbc/board.py @@ -0,0 +1,182 @@ +"""棋盘数据模型。 + +设计目标: +1. 网格尺寸动态可变,但恒为矩形(rows x cols)。每次识别都重新构建一个 Board。 +2. 每个格子有三种类型,且为后续扩展预留字段: + - NORMAL:正常方块,可移动/可交换,带颜色 (color)。 + - BRICK :砖块,不可移动、不可交换(障碍物)。可能被相邻消除破坏 —— 预留 meta。 + - EMPTY :空块/空洞,没有方块,不可交换。 +""" + +from __future__ import annotations + +from dataclasses import dataclass, field, replace +from enum import IntEnum +from typing import Iterator, Optional + + +class CellKind(IntEnum): + """格子类型。""" + + EMPTY = 0 # 空块 / 空洞:无方块 + BRICK = 1 # 砖块:不可移动的障碍 + NORMAL = 2 # 正常块:可交换,带颜色 + + +# 用于 NORMAL 块的“无颜色”占位(EMPTY / BRICK 的 color 恒为该值)。 +NO_COLOR: int = -1 + + +@dataclass +class Cell: + """单个格子。 + + color 仅在 kind == NORMAL 时有意义,其余情况为 NO_COLOR。 + meta 预留扩展位:例如砖块血量、特殊方块类型(条状/炸弹/彩球)、冰冻层数等。 + 后续编程时往这里塞结构化数据,而无需改动核心模型。 + """ + + kind: CellKind = CellKind.EMPTY + color: int = NO_COLOR + meta: dict = field(default_factory=dict) + + # ---- 类型判定(便于求解器阅读) ---- + @property + def is_normal(self) -> bool: + return self.kind == CellKind.NORMAL + + @property + def is_brick(self) -> bool: + return self.kind == CellKind.BRICK + + @property + def is_empty(self) -> bool: + return self.kind == CellKind.EMPTY + + @property + def is_swappable(self) -> bool: + """只有正常块可以被交换。""" + return self.kind == CellKind.NORMAL + + # ---- 便捷构造 ---- + @staticmethod + def normal(color: int, **meta) -> "Cell": + return Cell(kind=CellKind.NORMAL, color=color, meta=dict(meta)) + + @staticmethod + def brick(**meta) -> "Cell": + return Cell(kind=CellKind.BRICK, color=NO_COLOR, meta=dict(meta)) + + @staticmethod + def empty(**meta) -> "Cell": + return Cell(kind=CellKind.EMPTY, color=NO_COLOR, meta=dict(meta)) + + def clone(self) -> "Cell": + return replace(self, meta=dict(self.meta)) + + +class Board: + """矩形棋盘。坐标统一使用 (row, col),行从上到下、列从左到右,均从 0 起。""" + + def __init__(self, rows: int, cols: int, fill: Optional[Cell] = None) -> None: + if rows <= 0 or cols <= 0: + raise ValueError(f"棋盘尺寸必须为正:rows={rows}, cols={cols}") + self.rows = rows + self.cols = cols + self._grid: list[list[Cell]] = [ + [(fill.clone() if fill is not None else Cell.empty()) for _ in range(cols)] + for _ in range(rows) + ] + + # ---- 访问 ---- + def in_bounds(self, r: int, c: int) -> bool: + return 0 <= r < self.rows and 0 <= c < self.cols + + def get(self, r: int, c: int) -> Cell: + if not self.in_bounds(r, c): + raise IndexError(f"越界访问 ({r}, {c}),棋盘为 {self.rows}x{self.cols}") + return self._grid[r][c] + + def set(self, r: int, c: int, cell: Cell) -> None: + if not self.in_bounds(r, c): + raise IndexError(f"越界写入 ({r}, {c}),棋盘为 {self.rows}x{self.cols}") + self._grid[r][c] = cell + + def color_at(self, r: int, c: int) -> int: + """返回该格颜色;非 NORMAL 一律返回 NO_COLOR。""" + return self._grid[r][c].color if self._grid[r][c].is_normal else NO_COLOR + + def is_swappable(self, r: int, c: int) -> bool: + return self.in_bounds(r, c) and self._grid[r][c].is_swappable + + # ---- 遍历 ---- + def iter_cells(self) -> Iterator[tuple[int, int, Cell]]: + for r in range(self.rows): + for c in range(self.cols): + yield r, c, self._grid[r][c] + + + # ---- 复制 ---- + def clone(self) -> "Board": + new = Board(self.rows, self.cols) + for r in range(self.rows): + for c in range(self.cols): + new._grid[r][c] = self._grid[r][c].clone() + return new + + # ---- 构造助手:从二维颜色矩阵建棋盘 ---- + @classmethod + def from_colors( + cls, + matrix: list[list[int]], + brick: int = -2, + empty: int = NO_COLOR, + ) -> "Board": + """从二维整数矩阵快速构建棋盘(主要用于测试和调试)。 + + 约定: + matrix[r][c] == brick -> 砖块 + matrix[r][c] == empty -> 空块 + 其它非负整数 -> 正常块,整数即颜色 id + """ + if not matrix or not matrix[0]: + raise ValueError("matrix 不能为空") + rows = len(matrix) + cols = len(matrix[0]) + if any(len(row) != cols for row in matrix): + raise ValueError("矩阵必须为矩形(每行列数一致)") + + board = cls(rows, cols) + for r in range(rows): + for c in range(cols): + v = matrix[r][c] + if v == brick: + board.set(r, c, Cell.brick()) + elif v == empty: + board.set(r, c, Cell.empty()) + else: + board.set(r, c, Cell.normal(v)) + return board + + # ---- 调试输出 ---- + def to_str(self) -> str: + """字符化:. 空块, # 砖块, 数字/字母 正常块颜色。""" + lines = [] + for r in range(self.rows): + cells = [] + for c in range(self.cols): + cell = self._grid[r][c] + if cell.is_empty: + cells.append(".") + elif cell.is_brick: + cells.append("#") + else: + # 颜色 0-9 直接显示,>=10 用字母,便于肉眼对齐 + cells.append( + str(cell.color) if cell.color < 10 else chr(ord("A") + cell.color - 10) + ) + lines.append(" ".join(cells)) + return "\n".join(lines) + + def __repr__(self) -> str: + return f"Board({self.rows}x{self.cols})\n{self.to_str()}" diff --git a/src/hbc/calibrate.py b/src/hbc/calibrate.py new file mode 100644 index 0000000..71d2b5f --- /dev/null +++ b/src/hbc/calibrate.py @@ -0,0 +1,181 @@ +"""标定工具:从一张棋盘截图生成布局 + 导出格子图供你做模板。 + +本游戏共 7 关,网格逐关变大(且可能含空格)。布局**按关卡分文件**保存在 +layouts/ 文件夹下(layouts/level1.json、layouts/level2.json ...)。 +你不需要手算坐标,按下面三步走(命令均在项目根目录执行): + +1) 框出棋盘 + 填行列数 -> 写入 layouts/level1.json + python -m hbc.calibrate roi --image src-images/level1.jpg --level 1 --rows 5 --cols 5 + (弹出窗口会自动缩放到适配屏幕,鼠标拖出棋盘矩形,回车确认; + 没有 GUI / 不想拖,可加 --box x,y,w,h 直接给原图坐标) + +2) 叠加网格预览,确认对齐 + python -m hbc.calibrate preview --image src-images/level1.jpg --level 1 --out preview_level1.png + +3) 把每个格子切成小图,导出到 cells/,再人工分类到模板库 + python -m hbc.calibrate export --image src-images/level1.jpg --level 1 --out cells/ + 然后把 cells/*.png 分别拷进 templates/normal、templates/wall、templates/empty +""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +import cv2 + +from .config import DEFAULT_LAYOUTS_DIR, GridConfig, level_layout_path + + +def _parse_box(s: str) -> tuple[int, int, int, int]: + parts = [int(v) for v in s.split(",")] + if len(parts) != 4: + raise argparse.ArgumentTypeError("--box 需要 x,y,w,h 四个整数") + return parts[0], parts[1], parts[2], parts[3] + + +def _fit_scale(img_w: int, img_h: int, max_side: int) -> float: + """计算把图片缩到不超过 max_side 的缩放系数(<=1)。""" + longest = max(img_w, img_h) + return min(1.0, max_side / longest) if longest > 0 else 1.0 + + +def _map_box_to_original(box: tuple[int, int, int, int], scale: float) -> tuple[int, int, int, int]: + """把"缩放图上选的框"换算回原图像素坐标。""" + if scale <= 0 or scale >= 1.0: + return box + x, y, w, h = box + return round(x / scale), round(y / scale), round(w / scale), round(h / scale) + + +def _select_roi_fit(img, max_side: int) -> tuple[int, int, int, int]: + """在自适应屏幕的缩放窗口里框选,返回原图坐标的 (x, y, w, h)。""" + h, w = img.shape[:2] + scale = _fit_scale(w, h, max_side) + disp = img if scale >= 1.0 else cv2.resize( + img, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_AREA + ) + title = "select board (drag, Enter=OK, c=cancel)" + cv2.namedWindow(title, cv2.WINDOW_NORMAL) + cv2.resizeWindow(title, disp.shape[1], disp.shape[0]) + box = tuple(int(v) for v in cv2.selectROI(title, disp, showCrosshair=True)) + cv2.destroyAllWindows() + return _map_box_to_original(box, scale) # type: ignore[arg-type] + + +def cmd_roi(args) -> int: + img = cv2.imread(args.image, cv2.IMREAD_COLOR) + if img is None: + print(f"读不到图片:{args.image}") + return 1 + + if args.box: + x, y, w, h = args.box + else: + print("拖动鼠标框出棋盘范围(窗口已按屏幕缩放);回车/空格确认,c 取消。") + x, y, w, h = _select_roi_fit(img, args.max_display) + if w == 0 or h == 0: + print("未选择有效区域。") + return 2 + + ih, iw = img.shape[:2] + grid = GridConfig(x=x, y=y, w=w, h=h, rows=args.rows, cols=args.cols, ref_w=iw, ref_h=ih) + + path = level_layout_path(args.level, args.layouts_dir) + path.parent.mkdir(parents=True, exist_ok=True) + grid.save_json(path) + + print(f"已写入 {path}:") + print(f" ROI=({x},{y},{w},{h}) grid={args.rows}x{args.cols} " + f"cell≈{grid.cell_w:.1f}x{grid.cell_h:.1f}") + return 0 + + +def _load_grid(args) -> GridConfig: + return GridConfig.load_json(level_layout_path(args.level, args.layouts_dir)) + + +def cmd_preview(args) -> int: + img = cv2.imread(args.image, cv2.IMREAD_COLOR) + if img is None: + print(f"读不到图片:{args.image}") + return 1 + grid = _load_grid(args) + + vis = img.copy() + cv2.rectangle(vis, (grid.x, grid.y), (grid.x + grid.w, grid.y + grid.h), (0, 255, 0), 2) + for r in range(grid.rows + 1): + yy = grid.y + int(r * grid.cell_h) + cv2.line(vis, (grid.x, yy), (grid.x + grid.w, yy), (0, 200, 255), 1) + for c in range(grid.cols + 1): + xx = grid.x + int(c * grid.cell_w) + cv2.line(vis, (xx, grid.y), (xx, grid.y + grid.h), (0, 200, 255), 1) + for r in range(grid.rows): + for c in range(grid.cols): + px, py = grid.cell_center(r, c) + cv2.circle(vis, (px, py), 2, (0, 0, 255), -1) + + cv2.imwrite(args.out, vis) + print(f"已写入预览:{args.out}(绿框=棋盘,橙线=网格,红点=格子中心)") + return 0 + + +def cmd_export(args) -> int: + img = cv2.imread(args.image, cv2.IMREAD_COLOR) + if img is None: + print(f"读不到图片:{args.image}") + return 1 + grid = _load_grid(args) + out = Path(args.out) + out.mkdir(parents=True, exist_ok=True) + + n = 0 + for r in range(grid.rows): + for c in range(grid.cols): + x, y, w, h = grid.cell_box(r, c) + cell = img[y : y + h, x : x + w] + cv2.imwrite(str(out / f"L{args.level}_r{r:02d}_c{c:02d}.png"), cell) + n += 1 + print(f"已导出 {n} 个格子图到 {out}/") + print("下一步:把它们分类放进 templates/normal、templates/wall、templates/empty。") + return 0 + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser(prog="hbc.calibrate", description="棋盘标定与模板导出工具(多关卡)") + sub = p.add_subparsers(dest="cmd", required=True) + + pr = sub.add_parser("roi", help="框选棋盘并写入某关卡布局") + pr.add_argument("--image", required=True) + pr.add_argument("--level", type=int, required=True, help="关卡号 1~7") + pr.add_argument("--rows", type=int, required=True) + pr.add_argument("--cols", type=int, required=True) + pr.add_argument("--layouts-dir", default=DEFAULT_LAYOUTS_DIR) + pr.add_argument("--max-display", type=int, default=900, + help="框选窗口最长边像素上限(屏幕装不下时自动缩放)") + pr.add_argument("--box", type=_parse_box, help="无 GUI 时直接给原图 x,y,w,h") + pr.set_defaults(func=cmd_roi) + + pv = sub.add_parser("preview", help="叠加网格预览,检查对齐") + pv.add_argument("--image", required=True) + pv.add_argument("--level", type=int, required=True) + pv.add_argument("--layouts-dir", default=DEFAULT_LAYOUTS_DIR) + pv.add_argument("--out", default="preview.png") + pv.set_defaults(func=cmd_preview) + + pe = sub.add_parser("export", help="导出每个格子图,供制作模板") + pe.add_argument("--image", required=True) + pe.add_argument("--level", type=int, required=True) + pe.add_argument("--layouts-dir", default=DEFAULT_LAYOUTS_DIR) + pe.add_argument("--out", default="cells") + pe.set_defaults(func=cmd_export) + return p + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + return args.func(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/hbc/config.py b/src/hbc/config.py new file mode 100644 index 0000000..badc186 --- /dev/null +++ b/src/hbc/config.py @@ -0,0 +1,151 @@ +"""网格与识别配置。 + +把"屏幕像素"和"棋盘格子坐标"解耦,便于支持不同分辨率、不同尺寸的棋盘。 +网格尺寸 (rows, cols) 动态可变,但区域恒为矩形。 + +GridConfig 可序列化为 JSON(布局文件)。你只需在一张棋盘截图上框出棋盘范围、 +填好行列数,就能生成它——无需手算每个格子的像素坐标。 +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from pathlib import Path + + +@dataclass +class GridConfig: + """棋盘在屏幕上的几何信息。 + + 棋盘区域是一个矩形 ROI(左上角 x,y + 宽 w、高 h),被均匀切成 rows x cols 个格子。 + rows / cols 是可变的:识别阶段可以先探测出实际行列数再填进来。 + """ + + x: int # 棋盘区域左上角 x(像素) + y: int # 棋盘区域左上角 y(像素) + w: int # 棋盘区域宽度(像素) + h: int # 棋盘区域高度(像素) + rows: int # 行数(动态) + cols: int # 列数(动态) + ref_w: int = 0 # 标定时的图片宽(0=未知,不缩放) + ref_h: int = 0 # 标定时的图片高 + + # ---- 几何 ---- + @property + def cell_w(self) -> float: + return self.w / self.cols + + @property + def cell_h(self) -> float: + return self.h / self.rows + + def cell_center(self, r: int, c: int) -> tuple[int, int]: + """格子中心点的屏幕像素坐标,用于点击/滑动。""" + px = self.x + int((c + 0.5) * self.cell_w) + py = self.y + int((r + 0.5) * self.cell_h) + return px, py + + def cell_box(self, r: int, c: int) -> tuple[int, int, int, int]: + """格子的像素包围盒 (x, y, w, h),用于从截图裁剪该格做识别。""" + x = self.x + int(c * self.cell_w) + y = self.y + int(r * self.cell_h) + return x, y, int(self.cell_w), int(self.cell_h) + + def for_image(self, img_w: int, img_h: int) -> "GridConfig": + """把按 ref 尺寸标定的 ROI 缩放到实际截图尺寸。 + + 标定截图与运行时截屏分辨率常常不同(如标定 1080x2400、MaaFw 实时 720x1600)。 + 只要保留了 ref 尺寸,这里就能自动等比缩放,做到分辨率无关。 + """ + if not self.ref_w or not self.ref_h: + return self # 未记录参考尺寸:假定坐标已是当前分辨率 + if img_w == self.ref_w and img_h == self.ref_h: + return self + sx = img_w / self.ref_w + sy = img_h / self.ref_h + return GridConfig( + x=round(self.x * sx), y=round(self.y * sy), + w=round(self.w * sx), h=round(self.h * sy), + rows=self.rows, cols=self.cols, ref_w=img_w, ref_h=img_h, + ) + + # ---- 序列化(布局文件) ---- + def to_dict(self) -> dict: + return {"x": self.x, "y": self.y, "w": self.w, "h": self.h, + "rows": self.rows, "cols": self.cols, + "ref_w": self.ref_w, "ref_h": self.ref_h} + + @classmethod + def from_dict(cls, d: dict) -> "GridConfig": + return cls(int(d["x"]), int(d["y"]), int(d["w"]), int(d["h"]), + int(d["rows"]), int(d["cols"]), + int(d.get("ref_w", 0)), int(d.get("ref_h", 0))) + + def save_json(self, path: str | Path) -> None: + Path(path).write_text(json.dumps(self.to_dict(), indent=2, ensure_ascii=False), + encoding="utf-8") + + @classmethod + def load_json(cls, path: str | Path) -> "GridConfig": + return cls.from_dict(json.loads(Path(path).read_text(encoding="utf-8"))) + + +DEFAULT_LAYOUTS_DIR = "layouts" + + +def level_layout_path(level: int, layouts_dir: str | Path = DEFAULT_LAYOUTS_DIR) -> Path: + """某关卡的布局文件路径:/level{N}.json。 + + 7 关网格逐关变大,每关一个文件、文件名带关号,统一放在 layouts/ 文件夹下。 + """ + return Path(layouts_dir) / f"level{level}.json" + + +def load_level_grid(level: int, layouts_dir: str | Path = DEFAULT_LAYOUTS_DIR) -> GridConfig: + p = level_layout_path(level, layouts_dir) + if not p.exists(): + raise FileNotFoundError( + f"关卡 {level} 尚未标定(缺 {p})。请先运行:\n" + f" python -m hbc.calibrate roi --image 截图.png --level {level} --rows R --cols C" + ) + return GridConfig.load_json(p) + + +@dataclass +class RecognizeConfig: + """识别相关参数。 + + color_refs:颜色 id -> 参考 BGR 值。识别时取格子中心区域均值,找最近的参考色。 + 这是游戏相关的,需要你用真机截图标定后填入。 + color_dist_threshold:超过该距离视为"无法归类",可判为砖块/空块(结合形状/亮度进一步判断)。 + sample_ratio:取格子中心多大比例的区域求颜色均值(避免边框干扰)。 + """ + + color_refs: dict[int, tuple[int, int, int]] = field(default_factory=dict) + color_dist_threshold: float = 60.0 + sample_ratio: float = 0.5 + + +@dataclass +class TemplateMatchConfig: + """模板比对识别参数(比纯颜色更准,推荐)。 + + templates_dir:模板库目录,按子文件夹组织: + templates/ + normal/