"""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) @pytest.fixture 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