poolcoach / tests /test_autocorner.py
masterdanh's picture
deploy: snapshot for HF Space
78738de
Raw
History Blame Contribute Delete
9.17 kB
"""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