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