"""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)))