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