feat: workable

This commit is contained in:
2026-06-12 10:25:26 +08:00
commit 3a9af920b4
32 changed files with 2079 additions and 0 deletions
+187
View File
@@ -0,0 +1,187 @@
"""模板识别 + 标定工具测试(用合成图片,无需真机)。需要 opencv/numpy。"""
import numpy as np
import pytest
cv2 = pytest.importorskip("cv2")
from hbc.board import CellKind # noqa: E402
from hbc.calibrate import _fit_scale, _map_box_to_original # noqa: E402
from hbc.calibrate import main as calib_main # noqa: E402
from hbc.config import ( # noqa: E402
GridConfig,
TemplateMatchConfig,
level_layout_path,
load_level_grid,
)
from hbc.recognizer import recognize_board # noqa: E402
from hbc.templates import TemplateLibrary # noqa: E402
CELL = 40
# 合成棋盘布局:数字=正常块颜色, 'W'=墙(石), 'I'=墙(冰), '.'=空
LAYOUT = [
["0", "1", "2", "W"],
["1", "I", "0", "."],
["2", "0", "1", "2"],
]
# 每种外观对应的 BGR 纯色块(足够区分即可)
APPEARANCE = {
"0": (60, 60, 220), # 红
"1": (60, 200, 60), # 绿
"2": (220, 120, 60), # 蓝
"W": (90, 90, 90), # 石墙(灰)
"I": (230, 220, 180), # 冰墙(浅蓝白)
".": (10, 10, 10), # 空(近黑)
}
def _swatch(bgr):
img = np.zeros((CELL, CELL, 3), np.uint8)
img[:] = bgr
return img
def _build_board_image():
rows, cols = len(LAYOUT), len(LAYOUT[0])
img = np.zeros((rows * CELL, cols * CELL, 3), np.uint8)
for r in range(rows):
for c in range(cols):
img[r * CELL:(r + 1) * CELL, c * CELL:(c + 1) * CELL] = _swatch(APPEARANCE[LAYOUT[r][c]])
return img
def _write_templates(root):
(root / "normal").mkdir(parents=True)
(root / "wall").mkdir(parents=True)
(root / "empty").mkdir(parents=True)
cv2.imwrite(str(root / "normal" / "red.png"), _swatch(APPEARANCE["0"]))
cv2.imwrite(str(root / "normal" / "green.png"), _swatch(APPEARANCE["1"]))
cv2.imwrite(str(root / "normal" / "blue.png"), _swatch(APPEARANCE["2"]))
cv2.imwrite(str(root / "wall" / "stone.png"), _swatch(APPEARANCE["W"]))
cv2.imwrite(str(root / "wall" / "ice.png"), _swatch(APPEARANCE["I"]))
cv2.imwrite(str(root / "empty" / "hole.png"), _swatch(APPEARANCE["."]))
def test_template_recognition_multi_wall(tmp_path):
root = tmp_path / "templates"
_write_templates(root)
lib = TemplateLibrary.load(TemplateMatchConfig(templates_dir=str(root), canonical_size=32))
img = _build_board_image()
rows, cols = len(LAYOUT), len(LAYOUT[0])
grid = GridConfig(x=0, y=0, w=cols * CELL, h=rows * CELL, rows=rows, cols=cols)
board = recognize_board(img, grid, lib)
# 校验每格类型
for r in range(rows):
for c in range(cols):
cell = board.get(r, c)
token = LAYOUT[r][c]
if token in ("0", "1", "2"):
assert cell.kind == CellKind.NORMAL, (r, c, cell)
elif token in ("W", "I"):
assert cell.kind == CellKind.BRICK, (r, c, cell)
else:
assert cell.kind == CellKind.EMPTY, (r, c, cell)
# 两种墙应被区分到不同 meta['wall']
assert board.get(0, 3).meta["wall"] == "stone"
assert board.get(1, 1).meta["wall"] == "ice"
# 相同外观的正常块应得到相同 color id(求解器据此判等)
assert board.color_at(0, 0) == board.color_at(1, 2) # 两个红
assert board.color_at(0, 1) == board.color_at(1, 0) # 两个绿
assert board.color_at(0, 0) != board.color_at(0, 1) # 红 != 绿
def test_low_confidence_becomes_ice_wall(tmp_path):
"""置信度不足(如冰块/未登记方块)一律判为冰墙,而非空格。"""
root = tmp_path / "templates"
_write_templates(root) # 只有红/绿/蓝 + 墙 + 空
lib = TemplateLibrary.load(TemplateMatchConfig(templates_dir=str(root), canonical_size=32))
purple = _swatch((200, 40, 200)) # 库里没有的外观 -> 低置信度
cell = lib.classify(purple)
assert cell.is_brick and cell.meta.get("wall") == "ice"
# 真正的空块(与空模板一致,高分)仍判为空格
empty_cell = lib.classify(_swatch(APPEARANCE["."]))
assert empty_cell.is_empty and not empty_cell.is_brick
def test_layout_save_load(tmp_path):
p = tmp_path / "layout.json"
g = GridConfig(x=12, y=34, w=400, h=300, rows=6, cols=8)
g.save_json(p)
g2 = GridConfig.load_json(p)
assert g2.to_dict() == g.to_dict()
def test_level_layout_files(tmp_path):
"""7 关网格逐渐变大:每关一个 level{N}.json,文件名带关号,统一放 layouts/。"""
d = tmp_path / "layouts"
GridConfig(0, 0, 200, 200, 5, 5).save_json(_mk(level_layout_path(1, d)))
GridConfig(0, 0, 240, 240, 6, 6).save_json(level_layout_path(2, d))
GridConfig(0, 0, 280, 280, 8, 8).save_json(level_layout_path(7, d))
assert level_layout_path(1, d).name == "level1.json"
assert load_level_grid(1, d).rows == 5
assert load_level_grid(2, d).cols == 6
assert load_level_grid(7, d).w == 280
# 未标定的关卡应报错
import pytest as _pt
with _pt.raises(FileNotFoundError):
load_level_grid(3, d)
def _mk(p):
p.parent.mkdir(parents=True, exist_ok=True)
return p
def test_roi_scale_mapping():
"""缩放窗口里选的框,要能正确换算回原图坐标。"""
# 原图 1800 高,限制 900 -> scale=0.5
scale = _fit_scale(1000, 1800, 900)
assert abs(scale - 0.5) < 1e-9
# 在缩放图上选 (50,100,200,300) -> 原图应为两倍
assert _map_box_to_original((50, 100, 200, 300), scale) == (100, 200, 400, 600)
# 不需要缩放时原样返回
assert _fit_scale(400, 300, 900) == 1.0
assert _map_box_to_original((1, 2, 3, 4), 1.0) == (1, 2, 3, 4)
def test_calibrate_export_and_preview(tmp_path):
img_path = tmp_path / "board.png"
cv2.imwrite(str(img_path), _build_board_image())
layouts_dir = tmp_path / "layouts"
rows, cols = len(LAYOUT), len(LAYOUT[0])
GridConfig(0, 0, cols * CELL, rows * CELL, rows, cols).save_json(_mk(level_layout_path(1, layouts_dir)))
cells_dir = tmp_path / "cells"
rc = calib_main(["export", "--image", str(img_path), "--level", "1",
"--layouts-dir", str(layouts_dir), "--out", str(cells_dir)])
assert rc == 0
exported = list(cells_dir.glob("*.png"))
assert len(exported) == rows * cols
preview = tmp_path / "preview.png"
rc = calib_main(["preview", "--image", str(img_path), "--level", "1",
"--layouts-dir", str(layouts_dir), "--out", str(preview)])
assert rc == 0
assert preview.exists()
def test_calibrate_roi_with_box(tmp_path):
"""无 GUI:用 --box 直接给坐标,写入指定关卡文件。"""
img_path = tmp_path / "board.png"
cv2.imwrite(str(img_path), _build_board_image())
layouts_dir = tmp_path / "layouts"
rc = calib_main(["roi", "--image", str(img_path), "--level", "3",
"--rows", "5", "--cols", "5", "--box", "0,0,200,200",
"--layouts-dir", str(layouts_dir)])
assert rc == 0
g = load_level_grid(3, layouts_dir)
assert (g.x, g.y, g.w, g.h, g.rows, g.cols) == (0, 0, 200, 200, 5, 5)