File size: 10,822 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
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
"""Unit test shot segmentation (lát A1, BRIEF 13/08/2026) — track TỔNG HỢP.

Kiểm LÕI cắt ranh giới (``segment_shots`` — thuần numpy, chạy venv app,
không cv2/torch): cú đơn, đa cú, "mọi bi đứng yên" chứ không riêng cue,
coast/mất det giữa cú, mất cảnh, video tĩnh không đẻ cú ma, đệm biên.
KHÔNG kiểm YOLO/decode (``segment_video`` nghiệm thu trên VOD thật ở gate
A1.2) — cùng phân vai với test_broadcast_analytics vs gate G-24.3.

Ngưỡng vật lý trong kịch bản lấy từ bộ chốt 11/08 (import broadcast):
tốc độ cú > V_INIT 0.25 m/s, jitter bi tĩnh ~2mm/frame (p99 apparent speed
55 mm/s ở 30fps — đúng cỡ nhiễu đo được P0).
"""

from __future__ import annotations

import numpy as np
import pytest

from poolcoach_cv.broadcast import V_INIT_MPS, V_STILL_MPS
from poolcoach_cv.segmentation import (BLIND_MAX_S, PAD_POST_S, PAD_PRE_S,
                                       STILL_WIN_S, segment_shots)

FPS = 30.0


def _pos_at(t: float, p0, segs):
    """Vị trí theo lịch chuyển động: ``segs`` = [(t_a, t_b, p_a, p_b)] đoạn
    thẳng đều; ngoài đoạn thì đứng yên ở mút gần nhất."""
    x, y = p0
    for t_a, t_b, p_a, p_b in segs:
        if t < t_a:
            break
        if t >= t_b:
            x, y = p_b
        else:
            f = (t - t_a) / (t_b - t_a)
            x = p_a[0] + (p_b[0] - p_a[0]) * f
            y = p_a[1] + (p_b[1] - p_a[1]) * f
    return x, y


def build(dur_s, cue_p0, cue_segs=(), balls=(), cue_missing=(), blind=(),
          jitter=0.0, seed=7):
    """Track tổng hợp 30fps → (rows, others) đúng shape segment_shots.

    ``balls``: tuple (x, y) bi tĩnh, hoặc dict {"p0": (x,y), "segs": [...]}
    bi có lịch chạy riêng. ``cue_missing``/``blind``: list (t_a, t_b) —
    khoảng cue mất det / khoảng KHÔNG thấy bi nào (mất cảnh).
    """
    rng = np.random.default_rng(seed)
    rows, others = [], []
    n = int(round(dur_s * FPS)) + 1
    for i in range(n):
        t = i / FPS
        name = f"{i:05d}"
        is_blind = any(a <= t < b for a, b in blind)
        cue_gone = is_blind or any(a <= t < b for a, b in cue_missing)
        cx, cy = _pos_at(t, cue_p0, cue_segs)
        if jitter:
            cx += rng.normal(0, jitter)
            cy += rng.normal(0, jitter)
        rows.append({
            "frame_file": name, "t_s": round(t, 6),
            "covered": 0 if cue_gone else 1,
            "table_x_m": "" if cue_gone else cx,
            "table_y_m": "" if cue_gone else cy,
            # img_diff hằng 1.0 (bàn "đang sống") — không kích luật frame
            # trùng của find_dup_frames, thứ đã có test riêng ở broadcast
            "img_diff": 1.0 if i else -1.0,
        })
        if is_blind:
            continue
        for spec in balls:
            if isinstance(spec, dict):
                bx, by = _pos_at(t, spec["p0"], spec["segs"])
            else:
                bx, by = spec
            if jitter:
                bx += rng.normal(0, jitter)
                by += rng.normal(0, jitter)
            others.append({"frame_file": name, "t_s": round(t, 6),
                           "x_m": bx, "y_m": by})
    return rows, others


BALLS_TINH = [(0.4, 2.0), (0.9, 1.8), (0.6, 0.4)]


# ------------------------------------------------------------------ cú đơn

def test_cu_don_mot_cu_ok_moc_dung():
    rows, others = build(
        6.0, cue_p0=(0.3, 0.5),
        cue_segs=[(1.0, 2.0, (0.3, 0.5), (0.9, 1.7))],   # ~1.34 m/s
        balls=BALLS_TINH)
    out = segment_shots(rows, others)
    assert [s["status"] for s in out["shots"]] == ["ok"]
    s = out["shots"][0]
    assert s["idx"] == 1
    # onset đúng luật V_INIT_RUN bước > V_INIT — bắt tại lúc bắt đầu chạy
    assert s["t_onset_s"] == pytest.approx(1.0, abs=0.15)
    # settle trễ tối đa ~1 cửa sổ chord sau lúc dừng thật (2.0s)
    assert 2.0 <= s["t_settle_s"] <= 2.0 + 2 * STILL_WIN_S
    # đệm hai đầu, trong biên video
    assert s["t_start_s"] == pytest.approx(s["t_onset_s"] - PAD_PRE_S,
                                           abs=0.05)
    assert s["t_settle_s"] < s["t_end_s"] <= s["t_settle_s"] + PAD_POST_S
    assert 0.0 <= s["t_start_s"] < s["t_onset_s"]


def test_video_tinh_khong_de_cu_ma():
    """Bàn tĩnh 10s + jitter detect 2mm/frame (~p99 apparent speed 55mm/s
    của P0) — không được đẻ cú nào."""
    rows, others = build(10.0, cue_p0=(0.5, 1.0), balls=BALLS_TINH,
                         jitter=0.002)
    out = segment_shots(rows, others)
    assert out["shots"] == []


def test_track_rong_nem_valueerror():
    with pytest.raises(ValueError):
        segment_shots([])


# ------------------------------------------------------------------- đa cú

def test_da_cu_ba_cu_theo_thu_tu():
    segs = [(1.0, 2.0, (0.3, 0.5), (0.9, 1.7)),
            (5.0, 6.2, (0.9, 1.7), (0.4, 0.8)),
            (10.0, 10.8, (0.4, 0.8), (1.0, 2.2))]
    rows, others = build(14.0, cue_p0=(0.3, 0.5), cue_segs=segs,
                         balls=BALLS_TINH)
    out = segment_shots(rows, others)
    shots = out["shots"]
    assert [s["status"] for s in shots] == ["ok", "ok", "ok"]
    assert [s["idx"] for s in shots] == [1, 2, 3]
    for s, (t_a, _b, _pa, _pb) in zip(shots, segs):
        assert s["t_onset_s"] == pytest.approx(t_a, abs=0.15)
    # mốc thô tăng dần, không chồng nhau
    for a, b in zip(shots, shots[1:]):
        assert a["t_settle_s"] <= b["t_onset_s"]
        # đệm không nuốt sang chuyển động cú lân cận
        assert a["t_end_s"] <= b["t_onset_s"] + 1e-6
        assert b["t_start_s"] >= a["t_settle_s"] - 1e-6


def test_hai_cu_sat_nhau_van_tach():
    """Cú 2 nổ ngay sau khi bàn vừa yên lại — đóng cú xong phải armed luôn
    (không đợi thêm một cửa sổ yên nữa) mới bắt được onset kế."""
    segs = [(1.0, 2.0, (0.3, 0.5), (0.9, 1.7)),
            (3.2, 4.0, (0.9, 1.7), (0.5, 0.9))]
    rows, others = build(7.0, cue_p0=(0.3, 0.5), cue_segs=segs,
                         balls=BALLS_TINH)
    out = segment_shots(rows, others)
    assert [s["status"] for s in out["shots"]] == ["ok", "ok"]
    assert out["shots"][1]["t_onset_s"] == pytest.approx(3.2, abs=0.15)


# ------------------------------------------- "MỌI bi đứng yên", không chỉ cue

def test_bi_muc_tieu_con_lan_thi_chua_dong_cu():
    """Cue dừng ở 2.0 nhưng bi mục tiêu lăn tiếp tới 4.0 — settle phải chờ
    MỌI bi (định nghĩa ranh giới của design §4), không chốt sớm theo cue."""
    ball_lan = {"p0": (0.9, 1.8),
                "segs": [(2.0, 4.0, (0.9, 1.8), (0.5, 1.2))]}   # ~0.36 m/s
    rows, others = build(
        7.0, cue_p0=(0.3, 0.5),
        cue_segs=[(1.0, 2.0, (0.3, 0.5), (0.9, 1.7))],
        balls=[ball_lan, (0.4, 2.0)])
    out = segment_shots(rows, others)
    assert [s["status"] for s in out["shots"]] == ["ok"]
    assert out["shots"][0]["t_settle_s"] >= 4.0 - 0.1


# --------------------------------------------------- coast / mất det giữa cú

def test_cue_mat_det_ngan_giua_cu_khong_tach_cu():
    rows, others = build(
        6.0, cue_p0=(0.3, 0.5),
        cue_segs=[(1.0, 3.0, (0.3, 0.5), (1.1, 2.3))],
        balls=BALLS_TINH, cue_missing=[(1.8, 2.1)])
    out = segment_shots(rows, others)
    assert [s["status"] for s in out["shots"]] == ["ok"]
    assert out["shots"][0]["t_settle_s"] >= 3.0 - 0.1


def test_cue_mat_det_dai_hon_cua_so_van_khong_dong_som():
    """Cue biến mất TRỌN một cửa sổ chord khi đang bay (người che — bài học
    shot_07): mọi bi khác đứng yên nhưng chưa được kết luận 'đã yên' chỉ vì
    vắng mặt nhân chứng chính."""
    rows, others = build(
        6.0, cue_p0=(0.3, 0.5),
        cue_segs=[(1.0, 3.0, (0.3, 0.5), (1.1, 2.3))],
        balls=BALLS_TINH, cue_missing=[(1.5, 2.5)])
    out = segment_shots(rows, others)
    assert [s["status"] for s in out["shots"]] == ["ok"]
    s = out["shots"][0]
    # không được đóng trong khoảng cue tàng hình (1.5–2.5)
    assert s["t_settle_s"] >= 3.0 - 0.1


# ------------------------------------------------------------ mất cảnh (§5)

def test_mat_canh_giua_cu_bao_loi_khong_nuot():
    rows, others = build(
        9.0, cue_p0=(0.3, 0.5),
        cue_segs=[(1.0, 2.0, (0.3, 0.5), (0.9, 1.7)),
                  (6.0, 6.8, (0.9, 1.7), (0.5, 1.0))],
        balls=BALLS_TINH, blind=[(1.5, 4.0)])
    out = segment_shots(rows, others)
    shots = out["shots"]
    assert len(shots) == 2
    assert shots[0]["status"] == "error"
    assert "mất cảnh" in shots[0]["reason"]
    # cú sau khi cảnh quay lại + bàn yên lại vẫn bắt bình thường
    assert shots[1]["status"] == "ok"
    assert shots[1]["t_onset_s"] == pytest.approx(6.0, abs=0.15)


def test_blind_ngan_hon_nguong_khong_pha_cu():
    """Đứt detect ngắn (< BLIND_MAX_S) giữa cú — video giật/che thoáng qua
    — không được chẻ cú thành hai hay báo mất cảnh."""
    assert BLIND_MAX_S >= 0.5
    rows, others = build(
        6.0, cue_p0=(0.3, 0.5),
        cue_segs=[(1.0, 3.0, (0.3, 0.5), (1.1, 2.3))],
        balls=BALLS_TINH, blind=[(1.8, 2.1)])
    out = segment_shots(rows, others)
    assert [s["status"] for s in out["shots"]] == ["ok"]


def test_video_ket_thuc_giua_cu_bao_loi():
    rows, others = build(
        4.5, cue_p0=(0.3, 0.5),
        cue_segs=[(4.0, 6.0, (0.3, 0.5), (0.9, 1.7))],
        balls=BALLS_TINH)
    out = segment_shots(rows, others)
    assert len(out["shots"]) == 1
    assert out["shots"][0]["status"] == "error"
    assert "kết thúc" in out["shots"][0]["reason"]


# ------------------------------------------------------------------ biên/đệm

def test_dem_bien_khong_vuot_video():
    rows, others = build(
        3.0, cue_p0=(0.3, 0.5),
        cue_segs=[(0.8, 1.6, (0.3, 0.5), (0.9, 1.7))],
        balls=BALLS_TINH)
    out = segment_shots(rows, others)
    assert len(out["shots"]) == 1
    s = out["shots"][0]
    assert s["t_start_s"] >= 0.0
    assert s["t_end_s"] <= 3.0 + 1e-6
    assert s["t_start_s"] < s["t_onset_s"] < s["t_settle_s"] <= s["t_end_s"]


def test_nguong_dung_bo_chot_khong_bi_dinh_nghia_lai():
    """Khoá hợp đồng "dùng nguyên ngưỡng chốt 11/08": segmentation phải đọc
    V_STILL/V_INIT từ broadcast, không mang bản sao riêng có thể trôi."""
    import poolcoach_cv.broadcast as bc
    import poolcoach_cv.segmentation as seg

    assert seg.V_STILL_MPS is bc.V_STILL_MPS
    assert seg.V_INIT_MPS is bc.V_INIT_MPS
    assert seg.VMAX_MPS is bc.VMAX_MPS
    assert V_STILL_MPS == 0.055 and V_INIT_MPS == 0.25