Spaces:
Sleeping
Sleeping
File size: 7,925 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 | # -*- coding: utf-8 -*-
"""Unit nhiễu P0 của gen_synth_shots (BG26 bước 2, gate G-26.1b) — thuần
numpy, KHÔNG pooltool (phần thuần của module import lọt dưới stub conftest;
tầng sim gen_one/pooltool nghiệm thu bằng sanity + verify-repro lúc sinh).
Ba đặc trưng nhiễu là HỢP ĐỒNG (BRIEF bối cảnh 3): test khoá từng đặc trưng
xuất hiện ĐÚNG trong output làm bẩn, không chỉ "có gọi hàm":
1. ellipse dị hướng XOAY theo hướng vận tốc;
2. chu kỳ frame trùng 1/6 kiểu 25→30 upconvert (chép nguyên vị trí,
img_diff nhỏ, PTS vẫn đều);
3. dropout đầu cú đúng cửa sổ [strike, strike + frac·T) + gap ≤ 0.3s.
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "scripts" / "broadcast"))
import gen_synth_shots as gs # noqa: E402
def _straight_sim(heading_deg: float, speed: float = 2.0, dur: float = 2.0):
"""Quỹ đạo lý tưởng 1 bi chạy thẳng đều (T, 1, 2) @100Hz từ strike."""
t = np.arange(0.0, dur, 0.01)
d = np.stack([speed * t * np.cos(np.radians(heading_deg)),
speed * t * np.sin(np.radians(heading_deg))], axis=1)
xy = (np.array([0.45, 0.3]) + d)[:, None, :]
return t, xy
def _corrupt(rng, heading, *, fps=50, upconvert=False, still=0.5,
dropout=0.0, speed=2.0, dur=2.0):
t_sim, xy_sim = _straight_sim(heading, speed, dur)
return t_sim, xy_sim, gs.corrupt_shot(
rng, t_sim, xy_sim, np.array([np.inf]), fps, upconvert, still,
dropout)
def _residual(t_sim, xy_sim, out, still):
"""Nhiễu đo được = vị trí bẩn − lý tưởng tại đúng mốc frame."""
ts = np.clip(out["t"] - still, 0.0, float(t_sim[-1]))
ideal = np.stack([np.interp(ts, t_sim, xy_sim[:, 0, ax])
for ax in (0, 1)], axis=1)
ideal[out["t"] < still] = xy_sim[0, 0]
return out["xy"][:, 0, :] - ideal
def test_ellipse_di_huong_xoay_theo_huong_van_toc(monkeypatch):
"""Đặc trưng 1: chạy dọc +x → nhiễu x ≫ nhiễu y; chạy dọc +y thì NGƯỢC
LẠI — ellipse phải xoay theo vận tốc, không phải trục bàn. Cỡ nhiễu đúng
số P0: dọc ~13mm @2 m/s, ngang ~0.8mm, đứng yên ~0.3mm."""
monkeypatch.setattr(gs, "GAP_RATE_PER_BALL", 0.0)
for heading, i_along, i_perp in [(0.0, 0, 1), (90.0, 1, 0)]:
t_sim, xy_sim, out = _corrupt(np.random.default_rng(7), heading)
res = _residual(t_sim, xy_sim, out, 0.5)
moving = out["t"] >= 0.5 + 2 * 0.02
s_along = float(res[moving, i_along].std())
s_perp = float(res[moving, i_perp].std())
assert 0.008 < s_along < 0.020, (heading, s_along)
assert s_perp < 0.003, (heading, s_perp)
assert s_along > 4 * s_perp
still_part = res[out["t"] < 0.45]
assert float(np.abs(still_part).std()) < 0.0012
def test_chu_ky_frame_trung_1_6_kieu_upconvert(monkeypatch):
"""Đặc trưng 2: fps=30 upconvert → đúng chu kỳ 1 frame trùng mỗi 6;
frame trùng CHÉP NGUYÊN vị trí (cùng một lần đo) + img_diff nhỏ hơn hẳn
lân cận; PTS vẫn đều 1/30 (bẫy thật: chỉ img_diff lộ)."""
monkeypatch.setattr(gs, "GAP_RATE_PER_BALL", 0.0)
t_sim, xy_sim, out = _corrupt(np.random.default_rng(3), 80.0, fps=30,
upconvert=True, dur=3.0)
dup = out["dup_mask"]
n = len(dup)
assert dup.sum() == pytest.approx(n / 6, abs=2) # chu kỳ 1/6
idx = np.flatnonzero(dup)
assert np.all(np.diff(idx) == 6) # đều đặn mỗi 6
for i in idx:
assert np.array_equal(out["xy"][i], out["xy"][i - 1])
# PTS đều — không được lộ dup qua timestamp
assert np.allclose(np.diff(out["t"]), 1 / 30, atol=1e-6)
# img_diff: frame trùng < 0.3 × median lân cận đang chạy (luật dedup
# broadcast.find_dup_frames phải bắt được)
moving = out["t"] > 0.6
med = float(np.median(out["img_diff"][moving & ~dup]))
assert med > 0.08
assert out["img_diff"][idx].max() < 0.3 * med
# và find_dup_frames THẬT nuốt trọn các frame này
from poolcoach_cv.broadcast import find_dup_frames
names = [f"{i:05d}" for i in range(n)]
flagged = find_dup_frames(names, [float(v) for v in out["img_diff"]])
assert {names[i] for i in idx if out["t"][i] > 0.6} <= flagged
def test_khong_upconvert_khong_frame_trung(monkeypatch):
monkeypatch.setattr(gs, "GAP_RATE_PER_BALL", 0.0)
for fps in (25, 50, 60):
_t, _xy, out = _corrupt(np.random.default_rng(5), 45.0, fps=fps)
assert out["dup_mask"].sum() == 0
def test_dropout_dau_cu_dung_cua_so(monkeypatch):
"""Đặc trưng 3a: dropout 20% × T=2s → cue mất track ĐÚNG [strike,
strike+0.4s); ngoài cửa sổ (không gap) vẫn thấy."""
monkeypatch.setattr(gs, "GAP_RATE_PER_BALL", 0.0)
still = 1.0
t_sim, xy_sim, out = _corrupt(np.random.default_rng(11), 60.0,
still=still, dropout=0.2)
t = out["t"]
hide = (t >= still) & (t < still + 0.2 * float(t_sim[-1]))
assert hide.sum() >= 5
assert not out["covered"][hide, 0].any()
assert out["covered"][~hide, 0].all()
def test_gap_ngan_toi_da_03s():
"""Đặc trưng 3b: gap rải ngẫu nhiên — mỗi run mất track dài ≤ 0.3s
(+1 frame biên)."""
rng = np.random.default_rng(2) # seed này chắc chắn có gap (λ=3)
import unittest.mock as mock
with mock.patch.object(gs, "GAP_RATE_PER_BALL", 3.0):
t_sim, xy_sim, out = _corrupt(rng, 30.0, dropout=0.0)
cov = out["covered"][:, 0]
assert (~cov).sum() > 0
runs, cur = [], 0
for c in cov:
cur = cur + 1 if not c else 0
if cur:
runs.append(cur)
fps = 50
assert max(runs) <= int(0.30 * fps) + 1
def test_shard_roundtrip_bit_giong(tmp_path, monkeypatch):
"""Format npz: write_shard → iter_shots trả lại đúng bit từng cú (loader
này là đường đọc CHUNG của eval BG26 + train BG27)."""
monkeypatch.setattr(gs, "GAP_RATE_PER_BALL", 1.0)
metas, arrays = [], []
for i, (hd, fps, up) in enumerate([(0.0, 30, True), (90.0, 60, False),
(200.0, 25, False)]):
rng = np.random.default_rng(100 + i)
t_sim, xy_sim, out = _corrupt(rng, hd, fps=fps, upconvert=up,
dropout=0.1)
meta = {"label_v0": 2.0 + i, "label_phi": hd, "label_a": 0.1 * i,
"label_b": -0.1 * i, "v0_ball": 2.5, "phi_ball": hd,
"identifiable": i % 2, "scratch": 0, "potted_any": 1,
"n_bb": i, "n_cush": 2, "t_first_bb": 0.5,
"t_first_cush": float("nan"), "fps": fps,
"upconvert": int(up), "still_s": 0.5, "dropout_frac": 0.1,
"n_frames": len(out["t"]), "n_balls": 1, "shot_idx": i}
metas.append(meta)
arrays.append({"xy": out["xy"], "covered": out["covered"],
"img_diff": out["img_diff"],
"ball_ids": np.array([0], dtype=np.uint8)})
path = tmp_path / "train_0000000.npz"
gs.write_shard(path, metas, arrays)
shots = list(gs.iter_shots(path))
assert len(shots) == 3
for m, a, s in zip(metas, arrays, shots):
assert np.array_equal(s["xy"], a["xy"])
assert np.array_equal(s["covered"], a["covered"])
assert np.array_equal(s["img_diff"], a["img_diff"])
assert s["fps"] == m["fps"] and s["n_frames"] == m["n_frames"]
assert s["label_v0"] == np.float32(m["label_v0"])
assert len(s["t"]) == m["n_frames"]
assert s["t"][1] - s["t"][0] == pytest.approx(1 / m["fps"])
|