Spaces:
Sleeping
Sleeping
| """Contract gợi ý 4 góc bàn tự động (BRIEF 14/08/2026 việc A). | |
| Tầng hình học thuần numpy kiểm trực tiếp; tầng dò màu cần cv2 nên bọc | |
| ``importorskip`` — nếp "test app không đòi dep nặng" (conftest stub pooltool). | |
| Ảnh dùng cho ca end-to-end là ảnh TỔNG HỢP dựng từ chính homography của cú 1 | |
| pilot: không đọc dataset (test phải chạy được trên máy trắng), nhưng camera | |
| vẫn là camera thật nên van sanity BG32 có nghĩa. | |
| """ | |
| from __future__ import annotations | |
| import numpy as np | |
| import pytest | |
| from poolcoach_cv import autocorner as ac | |
| from poolcoach_cv.homography import (apply_homography, compute_homography, | |
| orient_corners, table_corners) | |
| # 4 góc mặt bàn cú 1 pilot (datasets/bb9_pilot/shot_01/homography.json, | |
| # Danh chấm 11/08) trên frame 1920×1080 — dùng làm camera thật cho ảnh tổng hợp | |
| PILOT_PX = np.array([[527.4947368421052, 869.6842105263157], | |
| [1380.1263157894737, 864.0], | |
| [1232.336842105263, 278.5263157894737], | |
| [673.0105263157894, 272.84210526315786]]) | |
| FRAME_WH = (1920, 1080) | |
| W, L = ac.TABLE_W_M, ac.TABLE_L_M | |
| # ------------------------------------------------------ hình học thuần | |
| def test_rut_bat_giac_ve_dung_hinh_vuong(): | |
| """Vuông bị VẠT 4 góc (đúng hình biên vải quanh miệng lỗ) → rút về đúng | |
| hình vuông gốc, vì bỏ cạnh vạt là phép làm phồng RẺ NHẤT.""" | |
| c = 20.0 | |
| octa = np.array([[c, 0], [100 - c, 0], [100, c], [100, 100 - c], | |
| [100 - c, 100], [c, 100], [0, 100 - c], [0, c]], | |
| dtype=np.float64) | |
| quad = ac.reduce_to_quad(octa) | |
| assert quad is not None | |
| got = np.sort(quad, axis=0) | |
| want = np.sort(np.array([[0, 0], [100, 0], [100, 100], [0, 100]], | |
| dtype=np.float64), axis=0) | |
| assert np.allclose(got, want, atol=1e-6) | |
| def test_rut_quad_giu_nguyen_tu_giac(): | |
| q = np.array([[0, 0], [10, 1], [11, 6], [1, 5]], dtype=np.float64) | |
| assert np.allclose(ac.reduce_to_quad(q), q) | |
| def test_order_quad_dua_bang_ngan_len_dau(): | |
| """Cặp cạnh đối LỆCH ĐỘ DÀI nhiều hơn là băng ngắn (camera dọc trục dài | |
| bàn): hai băng ngắn ở hai độ sâu rất khác nhau, hai băng dài thì không.""" | |
| q = np.array([[0, 300], [400, 300], # băng ngắn GẦN, dài 400px | |
| [330, 0], [70, 0]], dtype=np.float64) # băng ngắn XA, 260px | |
| out, info = ac.order_quad(q) | |
| assert info["swapped"] is False | |
| assert info["asym_pair0"] > info["asym_pair1"] | |
| # cạnh đầu của kết quả vẫn là một trong hai băng ngắn | |
| d0 = np.linalg.norm(out[1] - out[0]) | |
| assert min(abs(d0 - 400), abs(d0 - 260)) < 1e-6 | |
| def test_order_quad_xoay_khi_cap_kia_lech_hon(): | |
| q = np.array([[0, 300], [400, 300], [330, 0], [70, 0]], dtype=np.float64) | |
| out, info = ac.order_quad(q[[1, 2, 3, 0]]) | |
| assert info["swapped"] is True | |
| d0 = np.linalg.norm(out[1] - out[0]) | |
| assert min(abs(d0 - 400), abs(d0 - 260)) < 1e-6 | |
| def test_inset_quad_tra_dung_hinh_chu_nhat_trong(): | |
| """Thụt d mét trong HỆ BÀN: quad ngoài dựng từ hình chữ nhật (W+2d,L+2d) | |
| thì thụt lại phải ra đúng hình chiếu của W×L.""" | |
| d = 0.05 | |
| H = compute_homography(PILOT_PX, table_corners(W, L)) # px → bàn | |
| Hinv = np.linalg.inv(H) | |
| outer = apply_homography(Hinv, table_corners(W + 2 * d, L + 2 * d) - d) | |
| got = ac.inset_quad(outer, d, W, L) | |
| assert np.allclose(got, PILOT_PX, atol=1e-6) | |
| def test_inset_0_la_dong_nhat(): | |
| assert np.allclose(ac.inset_quad(PILOT_PX, 0.0, W, L), PILOT_PX) | |
| def test_corner_errors_cho_xoay_180_khong_cho_xoay_1(): | |
| """Xoay 2 nhịp = chấm từ đầu bàn kia, hiểu bàn y hệt → lệch ~0. | |
| Xoay 1 nhịp = gán nhầm băng ngắn ↔ băng dài → PHẢI lộ ra lệch lớn.""" | |
| err2, rot = ac.corner_errors_px(np.roll(PILOT_PX, 2, axis=0), PILOT_PX) | |
| assert rot == 2 and max(err2) < 1e-9 | |
| err1, _ = ac.corner_errors_px(np.roll(PILOT_PX, 1, axis=0), PILOT_PX) | |
| assert min(err1) > 100 | |
| # --------------------------------------------------- end-to-end (cần cv2) | |
| def _synth_frame(inner_px=PILOT_PX, rail_m=ac.RAIL_CLOTH_M): | |
| """Frame tổng hợp: mảng vải phủ trùm mép nose ``rail_m`` mét mỗi phía, | |
| trên nền tối — đúng thứ tầng dò phải xử lý (biên vải ≠ mép nose).""" | |
| cv2 = pytest.importorskip("cv2") | |
| H = compute_homography(inner_px, table_corners(W, L)) | |
| outer = apply_homography( | |
| np.linalg.inv(H), | |
| table_corners(W + 2 * rail_m, L + 2 * rail_m) - rail_m) | |
| img = np.full((FRAME_WH[1], FRAME_WH[0], 3), 30, dtype=np.uint8) | |
| cv2.fillPoly(img, [outer.astype(np.int32)], (196, 150, 120)) | |
| return img | |
| def test_suggest_tra_goc_gan_mep_nose_va_qua_van(): | |
| img = _synth_frame() | |
| res = ac.suggest_corners(img) | |
| assert res["ok"], res["reason"] | |
| errs, _ = ac.corner_errors_px(res["corners"], | |
| orient_corners(PILOT_PX)) | |
| # ảnh tổng hợp không nhiễu: sai số chỉ còn do lượng tử hoá ảnh làm việc | |
| assert max(errs) < 12, errs | |
| assert 1.5 <= res["camera"]["h_m"] <= 6.0 | |
| assert 800 <= res["camera"]["f_px"] <= 8000 | |
| def test_suggest_khong_thay_vai_thi_bao_ly_do_khong_nem(): | |
| pytest.importorskip("cv2") | |
| img = np.full((720, 1280, 3), 30, dtype=np.uint8) | |
| res = ac.suggest_corners(img) | |
| assert res["ok"] is False and res["corners"] is None | |
| assert res["reason"] | |
| def test_van_sanity_chan_quad_vo_ly(): | |
| """Mảng vải hình gần vuông (không phải bàn 2:1) → camera suy ra vô lý → | |
| KHÔNG đề xuất. Van là của camera.py, ở đây chỉ kiểm nó được gọi thật.""" | |
| cv2 = pytest.importorskip("cv2") | |
| img = np.full((1080, 1920, 3), 30, dtype=np.uint8) | |
| cv2.fillPoly(img, [np.array([[500, 300], [1400, 300], [1400, 800], | |
| [500, 800]], dtype=np.int32)], | |
| (196, 150, 120)) | |
| res = ac.suggest_corners(img) | |
| assert res["ok"] is False | |
| assert "van" in (res["reason"] or "").lower() \ | |
| or "Van" in (res["reason"] or "") | |
| def test_suggest_khong_nem_khi_anh_qua_nho(): | |
| pytest.importorskip("cv2") | |
| res = ac.suggest_corners(np.zeros((10, 10, 3), dtype=np.uint8)) | |
| assert res["ok"] is False and "nhỏ" in res["reason"] | |
| # ----------------------------- POST /api/analyzer/suggest-corners (việc A) | |
| def client(): | |
| from fastapi.testclient import TestClient | |
| from app import jobqueue | |
| from app import main as app_main | |
| jobqueue.teardown() # mode inprocess — gợi ý KHÔNG cần queue | |
| return TestClient(app_main.app) | |
| def _post_frame(client, img): | |
| cv2 = pytest.importorskip("cv2") | |
| ok, buf = cv2.imencode(".jpg", img) | |
| assert ok | |
| return client.post("/api/analyzer/suggest-corners", | |
| files={"image": ("frame.jpg", buf.tobytes(), | |
| "image/jpeg")}) | |
| def test_endpoint_tra_4_goc_o_mode_inprocess(client): | |
| """Gợi ý phải sống ở CẢ mode inprocess: nó là một lần dò màu thuần | |
| cv2/numpy trong app, không đụng CV worker (khác /api/analyzer/videos).""" | |
| r = _post_frame(client, _synth_frame()) | |
| assert r.status_code == 200 | |
| data = r.json() | |
| assert data["ok"] is True and len(data["corners"]) == 4 | |
| assert 1.5 <= data["camera"]["h_m"] <= 6.0 | |
| def test_endpoint_khong_de_xuat_van_la_200_kem_ly_do(client): | |
| pytest.importorskip("cv2") | |
| r = _post_frame(client, np.full((720, 1280, 3), 30, dtype=np.uint8)) | |
| assert r.status_code == 200 | |
| data = r.json() | |
| assert data["ok"] is False and data["reason"] | |
| assert "corners" not in data # exclude_none — FE không đọc nhầm | |
| def test_endpoint_anh_rong_422(client): | |
| r = client.post("/api/analyzer/suggest-corners", | |
| files={"image": ("f.jpg", b"", "image/jpeg")}) | |
| assert r.status_code == 422 | |
| def test_endpoint_khong_phai_anh_422(client): | |
| pytest.importorskip("cv2") | |
| r = client.post("/api/analyzer/suggest-corners", | |
| files={"image": ("f.jpg", b"khong-phai-anh" * 8, | |
| "image/jpeg")}) | |
| assert r.status_code == 422 | |
| # ------------------------------------------------- FE (hợp đồng tĩnh) | |
| def _static(name: str) -> str: | |
| from app.main import STATIC_DIR | |
| return (STATIC_DIR / name).read_text(encoding="utf-8") | |
| def test_fe_co_nut_xoay_nhan_va_keo_duoc_diem(): | |
| html, js = _static("index.html"), _static("app.js") | |
| assert 'id="an-rotate"' in html | |
| # kéo chỉnh = phải có cả 3 nhịp pointer, không chỉ pointerdown như bản cũ | |
| for ev in ("pointerdown", "pointermove", "pointerup"): | |
| assert f'$("#an-canvas").addEventListener("{ev}"' in js | |
| # nút quay về chấm tay GIỮ NGUYÊN (gate A.2: luồng cũ không đổi) | |
| assert 'id="an-redo"' in html | |
| assert "/api/analyzer/suggest-corners" in js | |