Spaces:
Sleeping
Sleeping
| """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))) | |