poolcoach / tests /test_cv_ballid.py
masterdanh's picture
deploy: snapshot for HF Space
78738de
Raw
History Blame Contribute Delete
7.73 kB
"""Unit BallID (Bước 1 BRIEF 06/08) — synthetic thuần numpy, không ảnh thật.
3 nhóm gate G2:
1. synthetic 9 crop màu palette + nhiễu kênh + ám vàng nhân tạo + bóng loé
→ 9/9 đúng số;
2. ĐỐI CHỨNG ÂM: cùng ảnh ám vàng nhưng TẮT chuẩn hoá trắng → sai ≥ 1
(chứng minh WB có tác dụng thật, không phải trang trí);
3. greedy: hai bi tranh một số, tie tất định, quá ngưỡng → None.
**Màu viết BGR TƯỜNG MINH** (quy ước OpenCV — kênh 0 là XANH DƯƠNG): nếu
module lẫn RGB↔BGR thì bi 2 (xanh dương, B=200) đọc thành cam và nhóm 1
đỏ ngay — test khoá đúng cái bẫy BRIEF #5.
Ảnh synthetic: 10 ô 40×40 xếp ngang — ô 0 là bi CUE trắng, ô 1..9 là bi
1..9. Bi 9 sọc: ~45% hàng trên trắng, còn lại vàng (white_ratio trong crop
co 60% ≈ 0.42 ∈ [0.3, 0.6]). Bi 3 có đốm loé trắng nhỏ (~4% crop — dưới
trần 0.15 của bi trơn). Vàng bi 1 cố ý LỆCH hue palette hơn vàng sọc bi 9:
ám vàng không WB làm sọc trắng của bi 9 "hoá vàng" → bi 9 giống bi 1 hơn
chính bi 1 → greedy gán chéo 1↔9 — đúng cặp lẫn kỳ vọng của design §4.
"""
from __future__ import annotations
import numpy as np
import pytest
from poolcoach_cv.ballid import BALLID, assign_numbers, identify_balls
TILE = 40
# BGR tường minh (B, G, R) — bi 1..9 + trắng. Đối chiếu palette HSV trong
# ballid.BALLID: hue dẫn ra lần lượt ≈ 69° (1, cố ý lệch), 217° (2),
# 359° (3), 282° (4), 30° (5), 140° (6), 0° (7), — (8, V thấp), 53° (9).
BALL_BGR = {
1: (60, 235, 210), # vàng lệch xanh lá nhẹ
2: (200, 90, 20), # xanh dương
3: (35, 30, 210), # đỏ
4: (140, 40, 110), # tím
5: (20, 130, 240), # cam
6: (60, 130, 25), # xanh lá
7: (35, 35, 120), # đỏ nâu
8: (30, 28, 25), # đen
9: (25, 210, 235), # vàng sọc — sát hue palette hơn bi 1
}
WHITE_BGR = (245, 245, 245)
CUE_BOX = (0, 0, TILE, TILE)
BALL_BOXES = [(n * TILE, 0, (n + 1) * TILE, TILE) for n in range(1, 10)]
def _build_board(seed: int = 0) -> np.ndarray:
"""Ảnh (40, 400, 3) BGR uint8: cue + bi 1..9, nhiễu kênh ±8 (tất định)."""
rng = np.random.default_rng(seed)
img = np.zeros((TILE, 10 * TILE, 3), dtype=np.float64)
img[:, :TILE] = WHITE_BGR
for n, bgr in BALL_BGR.items():
img[:, n * TILE:(n + 1) * TILE] = bgr
# bi 9 sọc: 45% hàng trên trắng (crop co 60% thấy ~42% trắng)
img[: int(0.45 * TILE), 9 * TILE:10 * TILE] = WHITE_BGR
# bóng loé trên bi 3: đốm trắng 5×5 giữa crop (~4% pixel crop)
img[18:23, 3 * TILE + 18:3 * TILE + 23] = WHITE_BGR
img += rng.integers(-8, 9, img.shape)
return np.clip(img, 0, 255).astype(np.uint8)
def _tint_yellow(img: np.ndarray) -> np.ndarray:
"""Ám vàng nhân tạo kiểu đèn quán: kênh B (index 0 — BGR!) còn 50%."""
out = img.astype(np.float64)
out[..., 0] *= 0.5
return out.astype(np.uint8)
# ------------------------------------------- nhóm 1: synthetic 9/9 đúng
def test_anh_trung_tinh_khong_can_wb_9tren9():
"""Đèn trung tính, không cue → vẫn 9/9 (WB là đối sách ám đèn, không
phải điều kiện sống của tầng màu)."""
nums, wb = identify_balls(_build_board(), BALL_BOXES, cue_box=None)
assert wb is False
assert [n for n, _ in nums] == [1, 2, 3, 4, 5, 6, 7, 8, 9]
def test_am_vang_co_wb_9tren9():
"""Ám vàng + chuẩn hoá trắng theo cue → 9/9 đúng, conf hợp lệ [0,1]."""
tinted = _tint_yellow(_build_board())
nums, wb = identify_balls(tinted, BALL_BOXES, cue_box=CUE_BOX)
assert wb is True
assert [n for n, _ in nums] == [1, 2, 3, 4, 5, 6, 7, 8, 9]
assert all(0.0 <= conf <= 1.0 for _, conf in nums)
def test_nhieu_seed_khac_van_9tren9():
"""Nhiễu kênh seed khác — median phải gồng được (không khớp seed may)."""
for seed in (1, 2, 3):
tinted = _tint_yellow(_build_board(seed))
nums, _ = identify_balls(tinted, BALL_BOXES, cue_box=CUE_BOX)
assert [n for n, _ in nums] == [1, 2, 3, 4, 5, 6, 7, 8, 9], f"seed {seed}"
# ------------------------------------- nhóm 2: đối chứng âm — tắt WB
def test_doi_chung_am_am_vang_tat_wb_sai_it_nhat_1():
"""Cùng ảnh ám vàng, KHÔNG đưa cue (tắt WB) → phải sai ≥ 1 bi.
Cơ chế dự kiến: sọc trắng bi 9 nhiễm vàng (S vượt ngưỡng trắng) →
white_ratio sụp về ~0 → bi 9 thành "bi vàng trơn" sát palette số 1 hơn
chính bi 1 → gán chéo 1↔9. Nếu test này đỏ tức WB đang là trang trí.
"""
tinted = _tint_yellow(_build_board())
nums, wb = identify_balls(tinted, BALL_BOXES, cue_box=None)
assert wb is False
wrong = [(i + 1, n) for i, (n, _) in enumerate(nums) if n != i + 1]
assert len(wrong) >= 1, "tắt WB mà vẫn 9/9 — đối chứng âm vô hiệu"
def test_wb_flag_khong_cue_la_false():
"""Cờ wb phản ánh đúng việc CÓ chuẩn hoá hay không (FE/toast tin nó)."""
img = _build_board()
assert identify_balls(img, BALL_BOXES, cue_box=CUE_BOX)[1] is True
assert identify_balls(img, BALL_BOXES, cue_box=None)[1] is False
# ----------------------------------------------- nhóm 3: greedy tầng 2
def test_greedy_hai_bi_tranh_mot_so():
"""Hai bi cùng mê số 3 — bi cost thấp thắng, bi kia nhận số đúng còn lại."""
c = np.full((2, 9), 5.0) # 5.0 > cost_max → mặc định ngoài cuộc
c[0, 2] = 0.10 # bi 0 → số 3, rẻ nhất
c[1, 2] = 0.12 # bi 1 cũng mê số 3 nhưng đắt hơn
c[1, 4] = 0.20 # đường lui của bi 1: số 5
out = assign_numbers(c)
assert out[0][0] == 3
assert out[1][0] == 5
def test_greedy_thua_tranh_chap_het_duong_ve_none():
c = np.full((2, 9), 5.0)
c[0, 2] = 0.10
c[1, 2] = 0.12 # bi 1 CHỈ mê số 3 — thua là hết đường
out = assign_numbers(c)
assert out[0][0] == 3
assert out[1] == (None, 0.0)
def test_greedy_tie_tat_dinh_index_nho_truoc():
"""Hoà cost tuyệt đối → bi index nhỏ lấy số index nhỏ; chạy 2 lần y hệt."""
c = np.full((2, 9), 5.0)
c[0, 2] = c[0, 3] = 0.10
c[1, 2] = c[1, 3] = 0.10
out1 = assign_numbers(c)
out2 = assign_numbers(c)
assert out1 == out2
assert out1[0][0] == 3 # bi 0 trước, trong hàng số index nhỏ trước
assert out1[1][0] == 4
def test_qua_cost_max_khong_gan_bua():
c = np.full((1, 9), BALLID["cost_max"] + 0.01)
assert assign_numbers(c) == [(None, 0.0)]
def test_muoi_bi_chi_gan_toi_da_9_so():
"""N > 9: mỗi số ≤ 1 bi là ràng buộc cứng — bi thừa về None."""
out = assign_numbers(np.zeros((10, 9)))
assert [n for n, _ in out[:9]] == [1, 2, 3, 4, 5, 6, 7, 8, 9]
assert out[9] == (None, 0.0)
def test_conf_bi_tranh_chap_thap_hon_bi_mot_minh():
"""margin với ứng viên nhì kéo conf xuống khi hai số tranh nhau."""
clear = np.full((1, 9), 5.0)
clear[0, 0] = 0.05 # một mình một chợ
contested = np.full((1, 9), 5.0)
contested[0, 0] = 0.05
contested[0, 1] = 0.06 # ứng viên nhì bám sát
conf_clear = assign_numbers(clear)[0][1]
conf_contested = assign_numbers(contested)[0][1]
assert conf_clear > conf_contested
def test_cost_shape_sai_bao_loi():
with pytest.raises(ValueError, match=r"\(N,9\)"):
assign_numbers(np.zeros((2, 5)))