happy-birds-cracker/tests/test_recognition.py

188 lines
6.9 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""模板识别 + 标定工具测试(用合成图片,无需真机)。需要 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)