Spaces:
Sleeping
Sleeping
File size: 9,167 Bytes
78738de | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 | """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
|