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