poolcoach / scripts /broadcast /gen_synth_shots.py
masterdanh's picture
deploy: snapshot for HF Space
78738de
Raw
History Blame Contribute Delete
31.2 kB
# -*- coding: utf-8 -*-
"""Sinh dataset synthetic BB9 cho net suy ngược P2 (BG26, 11/08/2026).
Pipeline mỗi cú: thế bàn random (tái dụng ``_place_random`` của
``scripts/eval_fullrack.py`` — cùng generator đã nghiệm thu engine) + action
4D đúng dải env RL → pooltool simulate (bàn default, KHÔNG chỉnh physics —
BRIEF bối cảnh 6) → quỹ đạo lý tưởng continuize 100Hz → LÀM BẨN theo mô hình
nhiễu P0 (bảng hằng NOISE_* dưới, số gốc HANDOFF 23) → shard ``.npz``.
Nhiễu P0 là HỢP ĐỒNG (BRIEF bối cảnh 3) — ba đặc trưng bắt buộc, thiếu một
là net BG27 đẹp trên sim chết trên thật (bài Kienzle 2023):
1. ellipse DỊ HƯỚNG xoay theo hướng vận tốc (dọc ~±13mm @2 m/s scale tuyến
tính theo tốc độ, ngang ~±0.8mm — HANDOFF 23 Bất ngờ 2);
2. chu kỳ frame TRÙNG 1/6 kiểu 25→30 upconvert khi fps=30 (dup-rate thô
16.7%, đo được qua img_diff ~11% — Bất ngờ 1): frame trùng CHÉP NGUYÊN
vị trí đã nhiễu của frame trước + img_diff nhỏ, PTS vẫn đều;
3. dropout ĐẦU cú 0–25% thời lượng chuyển động của cue (người cúi ngắm che
— shot_07 mất 24% đầu cú, Bất ngờ 3) + gap ngắn ≤0.3s rải ngẫu nhiên.
Label: (V0, phi, a, b) = ACTION đưa vào ``cue.set_state`` — V0 là tốc độ
GẬY. Probe strike (BG26b, ghi trong spec): tốc độ BI rời gậy =
V0 · 2/(1 + m/M + 2.5·(a²+b²)) = V0 · 2/(1.3 + 2.5(a²+b²)) (center 1.538×,
|a|=|b|=0.4 → 0.952×); heading bi lệch squirt tới ∓1.6° @|a|=0.4 (a>0 lệch
âm). ``v0_ball``/``phi_ball`` đo từ sim lưu kèm làm aux — harness baseline
cần chúng để quy đổi công bằng. ``identifiable`` = cue có ≥1 va chạm bi
hoặc băng trong sim (spin chỉ lộ qua va chạm — design §3.2).
Format ``.npz`` (đọc KHÔNG cần pooltool — BG27 train ở venv CV):
per-shot (S,): label_v0/phi/a/b, v0_ball, phi_ball, identifiable, scratch,
n_bb, n_cush, t_first_bb, t_first_cush (giây kể từ strike; NaN nếu
không có), fps, upconvert, still_s, dropout_frac, n_frames, n_balls,
shot_idx;
ragged + offset (S+1,): xy (Σ F_i·B_i, 2 — thứ tự [frame, ball], ball 0
LUÔN là cue), covered (Σ F_i·B_i), img_diff (Σ F_i), ball_ids (Σ B_i);
offsets ``xy_off``/``f_off``/``b_off``.
PTS KHÔNG lưu: t_s = arange(n_frames)/fps — CFR thật, PTS frame trùng vẫn
hợp lệ (đúng bẫy đã đo, chỉ img_diff lộ).
Split CHỐT TRƯỚC KHI SINH: rng mỗi cú = default_rng([BASE_SEED, split, idx])
với split 0 = train (150k), 1 = held-out (5k) — hai stream độc lập, thêm
worker hay đổi shard KHÔNG đổi nội dung cú. Tái lập: chạy lại idx nào cũng
ra đúng bit (gate G-26.3 kiểm 100 cú đầu).
Chạy (venv app ``poolcoach-env`` — pooltool ở đây, CPU):
python scripts/broadcast/gen_synth_shots.py --out "D:/Khoa luan/datasets/bb9_synth"
# tuỳ chọn: --workers 12 --train 150000 --holdout 5000 --sanity --verify 100
"""
from __future__ import annotations
import argparse
import json
import math
import subprocess
import sys
import time
from pathlib import Path
import numpy as np
ROOT = Path(__file__).resolve().parents[2]
# ------------------------------------------------------------ bảng hằng
# MỘT chỗ duy nhất — đổi số là đổi Ở ĐÂY, kèm nguồn (nếp broadcast.py).
BASE_SEED = 20260811 # ngày chốt spec — split + mọi rng con treo từ đây
SPLIT_TRAIN, SPLIT_HELDOUT = 0, 1
# Action 4D — dải đọc từ PositionPlayEnv.step (position_env.py):
# phi = (act+1)/2·360 → [0,360); V0 = 0.5+(act+1)/2·3.5 → [0.5, 4.0];
# a = b = act·0.4 → [−0.4, 0.4]. BRIEF nới RIÊNG V0 sample [0.5, 8] m/s
# để phủ cả cú safety chậm (vùng cú 11) lẫn cú break.
PHI_RANGE = (0.0, 360.0)
V0_RANGE = (0.5, 8.0)
AB_MAX = 0.4
N_BALLS_RANGE = (2, 9) # số bi mục tiêu (BRIEF: 2–9 bi + cue); luôn giữ
# bi 9 (nếp _gen_table eval_fullrack), subset 1..8
STILL_RANGE_S = (0.5, 1.5) # cửa sổ đứng yên đầu clip (định nghĩa cú §5:
# mọi-bi-đứng-yên → đánh; HANDOFF 23 Bất ngờ 5)
SIM_MAX_S = 20.0 # cắt quỹ đạo sim (analyze cap 30s cả clip)
# --- nhiễu P0 (HANDOFF 23, số gốc; chi tiết fit tự quyết ghi tại chỗ) ---
NOISE_STILL_SIGMA_M = 0.32e-3 / math.sqrt(2 * math.log(2))
# jitter tĩnh: r p50 = 0.32mm (37 bi đứng yên) →
# σ/trục = p50/√(2ln2) ≈ 0.272mm (Gauss 2D đẳng
# hướng; đuôi p99 4.94mm KHÔNG mô hình — khai)
NOISE_ALONG_M_PER_MPS = 13e-3 / 2.0
# blur dọc hướng chạy ±13mm đo ở cú ~2 m/s (median
# tốc độ 10 cú P0) → fit TUYẾN TÍNH theo tốc độ
# (blur ∝ v·exposure), 6.5mm/(m/s)
NOISE_ALONG_MAX_M = 26e-3 # bão hoà 2× mốc đo (bbox blur không dài vô hạn —
# tự quyết, chưa có số đo trên 4 m/s)
NOISE_PERP_M = 0.8e-3 # vuông góc hướng chạy ~±0.8mm, không scale
V_BLUR_MIN_MPS = 0.055 # dưới ngưỡng đứng yên (V_STILL broadcast) coi như
# không blur — chỉ còn jitter tĩnh
FPS_CHOICES = (25, 30, 50, 60)
FPS_WEIGHTS = (0.2, 0.4, 0.2, 0.2) # 30fps nặng hơn — phổ broadcast thật
P_UPCONVERT_30 = 0.5 # trong cú 30fps: nửa là 25fps upconvert (stream
# pilot chính là loại này — HANDOFF 23 Bất ngờ 1)
DROPOUT_MAX_FRAC = 0.25 # đầu cú: U(0, 0.25) thời lượng chạy của cue
GAP_RATE_PER_BALL = 0.7 # số gap ngắn ~ Poisson(0.7) mỗi bi
GAP_RANGE_S = (0.06, 0.30)
# img_diff tổng hợp — calib theo luật dedup broadcast (DUP_REL 0.3, sàn
# median 0.08) + cỡ số fixture P0 (moving ~0.5–0.8, still ~0.02):
IMG_DIFF_PER_M = 12.0 # 1 bi 2 m/s @30fps: 0.067m·12 ≈ 0.8
IMG_DIFF_STILL = 0.03 # nhiễu sensor cảnh tĩnh (< sàn 0.08 — không cờ)
IMG_DIFF_NOISE = 0.01
IMG_DIFF_DUP = 0.012 # frame trùng: gần 0 nhưng không phải 0
SHARD_SIZE = 6250 # 150k = 24 shard; held-out 5k = 1 shard
SPEC_GIT_COPY = ROOT / "scripts" / "broadcast" / "synth_spec.json"
# ---------------------------------------------- thuần numpy (unit-testable)
def frame_grid(duration_s: float, fps: int, upconvert: bool):
"""Lưới frame CFR: PTS đều ``i/fps``; nếu ``upconvert`` (25→30) thì nội
dung lấy ở 25fps và cứ 6 frame ra có 1 frame CHÉP nội dung frame trước
(dup) — PTS của frame trùng vẫn hợp lệ, đúng bẫy HANDOFF 23 Bất ngờ 1.
Trả (t_out (F,), content_idx (F,) int, t_content (C,), dup_mask (F,)):
``content_idx[i]`` trỏ vào ``t_content`` — frame trùng trỏ CÙNG một chỉ
số với frame trước nó."""
n = max(int(math.floor(duration_s * fps)), 2)
t_out = np.arange(n) / fps
if upconvert and fps == 30:
src = np.floor(np.arange(n) * 25.0 / 30.0).astype(np.int64)
t_content = np.arange(src.max() + 1) / 25.0
content_idx = src
else:
t_content = t_out.copy()
content_idx = np.arange(n)
dup_mask = np.zeros(n, dtype=bool)
dup_mask[1:] = content_idx[1:] == content_idx[:-1]
return t_out, content_idx, t_content, dup_mask
def ellipse_noise(rng: np.random.Generator, vel: np.ndarray) -> np.ndarray:
"""Nhiễu vị trí per-mẫu: ellipse xoay theo HƯỚNG VẬN TỐC ``vel`` (C, 2).
σ_dọc = √(jitter² + blur_dọc(v)²), σ_ngang = √(jitter² + 0.8mm²) khi bi
chạy (v > V_BLUR_MIN); bi đứng yên → jitter tròn 0.272mm. Mỗi mẫu độc
lập (per-frame bbox center — đúng cách P0 đo)."""
speed = np.hypot(vel[:, 0], vel[:, 1])
moving = speed > V_BLUR_MIN_MPS
blur_along = np.clip(NOISE_ALONG_M_PER_MPS * speed, 0.0,
NOISE_ALONG_MAX_M) * moving
s_along = np.sqrt(NOISE_STILL_SIGMA_M ** 2 + blur_along ** 2)
s_perp = np.where(moving,
math.hypot(NOISE_STILL_SIGMA_M, NOISE_PERP_M),
NOISE_STILL_SIGMA_M)
e_along = rng.standard_normal(len(vel)) * s_along
e_perp = rng.standard_normal(len(vel)) * s_perp
with np.errstate(invalid="ignore"):
u = np.where(moving[:, None], vel / np.maximum(speed, 1e-9)[:, None],
0.0)
# bi đứng yên: hướng ellipse vô nghĩa → trục chuẩn (σ hai trục bằng nhau)
u[~moving] = (1.0, 0.0)
perp = np.stack([-u[:, 1], u[:, 0]], axis=1)
return u * e_along[:, None] + perp * e_perp[:, None]
def corrupt_shot(rng: np.random.Generator, t_sim: np.ndarray,
xy_sim: np.ndarray, vis_until: np.ndarray, fps: int,
upconvert: bool, still_s: float, dropout_frac: float):
"""Quỹ đạo lý tưởng → quan sát bẩn (ba đặc trưng P0 + jitter + img_diff).
``t_sim`` (T,) giây kể từ STRIKE; ``xy_sim`` (T, B, 2) — bi 0 là cue;
``vis_until`` (B,) giây sim mà bi biến mất (vào lỗ; inf nếu còn trên
bàn). Trả dict: t (F,), xy (F, B, 2) float32, covered (F, B) bool,
img_diff (F,) float32, dup_mask (F,).
Timeline clip: [0, still_s) mọi bi đứng ở vị trí đầu; strike tại
``still_s``; nội dung sau đó nội suy tuyến tính từ quỹ đạo sim 100Hz.
Frame trùng CHÉP NGUYÊN vị trí đã nhiễu (cùng một lần đo) — chuỗi vận
tốc thô vì thế dip về 0 mỗi 6 bước nếu không dedup, đúng triệu chứng
thật."""
T = float(t_sim[-1])
duration = still_s + T
t_out, content_idx, t_content, dup_mask = frame_grid(duration, fps,
upconvert)
B = xy_sim.shape[1]
C = len(t_content)
# vị trí + vận tốc lý tưởng tại từng mốc NỘI DUNG (nhiễu sinh per nội
# dung — frame trùng dùng lại nguyên realization)
ts = np.clip(t_content - still_s, 0.0, T)
pos = np.empty((C, B, 2))
vel = np.zeros((C, B, 2))
v_sim = np.gradient(xy_sim, t_sim, axis=0) if len(t_sim) > 2 else \
np.zeros_like(xy_sim)
pre_still = t_content < still_s
for b_i in range(B):
for ax in (0, 1):
pos[:, b_i, ax] = np.interp(ts, t_sim, xy_sim[:, b_i, ax])
vel[:, b_i, ax] = np.interp(ts, t_sim, v_sim[:, b_i, ax])
vel[pre_still, b_i, :] = 0.0
pos[pre_still, b_i, :] = xy_sim[0, b_i, :]
noisy = np.empty_like(pos)
for b_i in range(B):
noisy[:, b_i, :] = pos[:, b_i, :] + ellipse_noise(rng, vel[:, b_i, :])
xy_out = noisy[content_idx].astype(np.float32) # (F, B, 2)
# ------- visibility: bi vào lỗ biến mất; dropout đầu cú (cue); gap ngắn
covered = np.ones((len(t_out), B), dtype=bool)
for b_i in range(B):
covered[:, b_i] &= (t_out - still_s) <= vis_until[b_i] + 1e-9
# dropout đầu cú: từ strike, phủ dropout_frac × thời lượng chạy của cue
# (shot_07: người cúi ngắm che cue quanh lúc đánh)
if dropout_frac > 0:
t_hide0, t_hide1 = still_s, still_s + dropout_frac * T
covered[(t_out >= t_hide0) & (t_out < t_hide1), 0] = False
# gap ngắn ≤0.3s rải ngẫu nhiên, độc lập từng bi
for b_i in range(B):
for _ in range(rng.poisson(GAP_RATE_PER_BALL)):
g = rng.uniform(*GAP_RANGE_S)
g0 = rng.uniform(0.0, max(duration - g, 1e-3))
covered[(t_out >= g0) & (t_out < g0 + g), b_i] = False
# ------- img_diff tổng hợp trên NỘI DUNG (frame trùng ~0)
step_motion = np.zeros(len(t_out))
d_content = np.zeros(C)
if C > 1:
d_content[1:] = np.abs(np.diff(pos, axis=0)).sum(axis=(1, 2))
step_motion = d_content[content_idx]
step_motion[dup_mask] = 0.0
img_diff = (IMG_DIFF_PER_M * step_motion
+ np.abs(rng.normal(IMG_DIFF_STILL, IMG_DIFF_NOISE,
len(t_out))))
img_diff[dup_mask] = IMG_DIFF_DUP * rng.uniform(0.5, 1.5,
int(dup_mask.sum()))
img_diff[0] = -1.0 # nếp pipeline thật (frame đầu)
return {"t": t_out.astype(np.float32), "xy": xy_out,
"covered": covered, "img_diff": img_diff.astype(np.float32),
"dup_mask": dup_mask}
def sample_shot_params(rng: np.random.Generator):
"""Action + fps + still + dropout cho MỘT cú — mọi lựa chọn từ ``rng``
của cú đó (tái lập từng cú độc lập)."""
phi = float(rng.uniform(*PHI_RANGE))
v0 = float(rng.uniform(*V0_RANGE))
a = float(rng.uniform(-AB_MAX, AB_MAX))
b = float(rng.uniform(-AB_MAX, AB_MAX))
fps = int(rng.choice(FPS_CHOICES, p=FPS_WEIGHTS))
upconvert = bool(fps == 30 and rng.random() < P_UPCONVERT_30)
still_s = float(rng.uniform(*STILL_RANGE_S))
dropout = float(rng.uniform(0.0, DROPOUT_MAX_FRAC))
return phi, v0, a, b, fps, upconvert, still_s, dropout
# ------------------------------------------------------------- tầng sim
def _sim_env():
"""Lazy import pooltool + generator thế bàn (tái dụng eval_fullrack)."""
sys.path.insert(0, str(ROOT / "src"))
sys.path.insert(0, str(ROOT / "scripts"))
import pooltool as pt
from eval_fullrack import _place_random # generator nghiệm thu
return pt, _place_random
def sample_board(rng, place_random, w, l, ball_r):
"""Thế bàn 2–9 bi + cue: subset 1..8 + LUÔN bi 9 (nếp _gen_table
eval_fullrack, mở rộng đủ dải 2–9 theo BRIEF)."""
n_obj = int(rng.integers(N_BALLS_RANGE[0], N_BALLS_RANGE[1] + 1))
if n_obj == 9:
ids = [str(i) for i in range(1, 10)]
else:
lows = sorted(rng.choice(np.arange(1, 9), size=n_obj - 1,
replace=False))
ids = [str(int(i)) for i in lows] + ["9"]
pts = place_random(rng, w, l, len(ids) + 1, ball_r)
return dict(zip(["cue"] + ids, pts))
def gen_one(pt, place_random, table, idx: int, split: int):
"""Sinh MỘT cú (sim + làm bẩn). Trả (meta dict, arrays dict) hoặc ném
RuntimeError nếu 5 lần action liên tiếp đều làm pooltool chết (chưa từng
thấy — đếm resample vào meta)."""
rng = np.random.default_rng([BASE_SEED, split, idx])
board = sample_board(rng, place_random, table.w, table.l,
float(table.balls_R) if hasattr(table, "balls_R")
else 0.028575)
for attempt in range(5):
phi, v0, a, b, fps, upconvert, still_s, dropout = \
sample_shot_params(rng)
system = pt.System(
table=table,
balls={bid: pt.Ball.create(bid, xy=tuple(xy))
for bid, xy in board.items()},
cue=pt.Cue(cue_ball_id="cue"))
system.cue.set_state(V0=v0, phi=phi, a=a, b=b)
try:
pt.simulate(system, inplace=True)
break
except Exception:
continue
else:
raise RuntimeError(f"shot {split}/{idx}: 5 action lien tiep loi sim")
ball_ids = list(board.keys()) # "cue" đứng đầu (dict giữ thứ tự)
# --- events của cue + thời điểm bi biến mất (vào lỗ)
n_bb = n_cush = 0
t_first_bb = t_first_cush = math.nan
vis_until = {bid: math.inf for bid in ball_ids}
for ev in system.events:
et = str(ev.event_type)
ids = tuple(getattr(ev, "ids", ()))
if "cue" in ids:
if et == "ball_ball":
n_bb += 1
if math.isnan(t_first_bb):
t_first_bb = float(ev.time)
elif "cushion" in et:
n_cush += 1
if math.isnan(t_first_cush):
t_first_cush = float(ev.time)
if "pocket" in et:
for bid in ids:
if bid in vis_until:
vis_until[bid] = min(vis_until[bid], float(ev.time))
identifiable = (n_bb + n_cush) > 0
scratch = math.isfinite(vis_until["cue"])
potted_any = any(math.isfinite(vis_until[bid]) for bid in ball_ids
if bid != "cue")
# --- v0/phi thật của BI (aux label — xem docstring module)
rvw_ev, _ss, _ts = system.balls["cue"].history.vectorize()
v_vec = rvw_ev[1, 1, :2] if len(rvw_ev) > 1 else np.zeros(2)
v0_ball = float(np.hypot(*v_vec))
phi_ball = float(np.degrees(np.arctan2(v_vec[1], v_vec[0])) % 360.0)
# --- quỹ đạo continuize 100Hz, cắt SIM_MAX_S
pt.continuize(system, dt=0.01, inplace=True)
xy_list, t_ref = [], None
for bid in ball_ids:
rvw, _s2, ts2 = system.balls[bid].history_cts.vectorize()
if t_ref is None:
t_ref = np.asarray(ts2, dtype=np.float64)
xy_list.append(rvw[: len(t_ref), 0, :2])
n_keep = int(np.searchsorted(t_ref, SIM_MAX_S, side="right"))
t_sim = t_ref[:n_keep] if n_keep >= 2 else t_ref
xy_sim = np.stack([x[: len(t_sim)] for x in xy_list], axis=1)
dirty = corrupt_shot(rng, t_sim, xy_sim,
np.array([vis_until[bid] for bid in ball_ids]),
fps, upconvert, still_s, dropout)
meta = {"label_v0": v0, "label_phi": phi, "label_a": a, "label_b": b,
"v0_ball": v0_ball, "phi_ball": phi_ball,
"identifiable": int(identifiable), "scratch": int(scratch),
"potted_any": int(potted_any), "n_bb": n_bb, "n_cush": n_cush,
"t_first_bb": t_first_bb, "t_first_cush": t_first_cush,
"fps": fps, "upconvert": int(upconvert), "still_s": still_s,
"dropout_frac": dropout, "n_frames": len(dirty["t"]),
"n_balls": len(ball_ids), "shot_idx": idx}
arrays = {"xy": dirty["xy"], "covered": dirty["covered"],
"img_diff": dirty["img_diff"],
"ball_ids": np.array([0 if bid == "cue" else int(bid)
for bid in ball_ids], dtype=np.uint8)}
return meta, arrays
META_FIELDS = [
("label_v0", np.float32), ("label_phi", np.float32),
("label_a", np.float32), ("label_b", np.float32),
("v0_ball", np.float32), ("phi_ball", np.float32),
("identifiable", np.uint8), ("scratch", np.uint8),
("potted_any", np.uint8), ("n_bb", np.uint16), ("n_cush", np.uint16),
("t_first_bb", np.float32), ("t_first_cush", np.float32),
("fps", np.uint8), ("upconvert", np.uint8), ("still_s", np.float32),
("dropout_frac", np.float32), ("n_frames", np.uint32),
("n_balls", np.uint8), ("shot_idx", np.uint32),
]
def write_shard(path: Path, metas: list[dict], arrays: list[dict]) -> None:
out = {}
for name, dt in META_FIELDS:
out[name] = np.array([m[name] for m in metas], dtype=dt)
xy = np.concatenate([a["xy"].reshape(-1, 2) for a in arrays])
covered = np.concatenate([a["covered"].reshape(-1) for a in arrays])
img_diff = np.concatenate([a["img_diff"] for a in arrays])
ball_ids = np.concatenate([a["ball_ids"] for a in arrays])
nf = out["n_frames"].astype(np.int64)
nb = out["n_balls"].astype(np.int64)
out["xy"] = xy.astype(np.float32)
out["covered"] = covered.astype(np.uint8)
out["img_diff"] = img_diff.astype(np.float32)
out["ball_ids"] = ball_ids
out["xy_off"] = np.concatenate([[0], np.cumsum(nf * nb)]).astype(np.uint64)
out["f_off"] = np.concatenate([[0], np.cumsum(nf)]).astype(np.uint64)
out["b_off"] = np.concatenate([[0], np.cumsum(nb)]).astype(np.uint64)
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".tmp.npz")
np.savez_compressed(tmp, **out)
tmp.replace(path)
def iter_shots(shard_path: Path):
"""Đọc shard → yield dict mỗi cú (t dựng lại từ fps — PTS CFR, xem
docstring format). KHÔNG cần pooltool — BG27/eval dùng chung loader."""
z = np.load(shard_path)
S = len(z["shot_idx"])
for i in range(S):
F = int(z["n_frames"][i])
B = int(z["n_balls"][i])
x0, f0, b0 = (int(z["xy_off"][i]), int(z["f_off"][i]),
int(z["b_off"][i]))
shot = {name: z[name][i].item() for name, _dt in META_FIELDS}
shot["t"] = np.arange(F) / float(z["fps"][i])
shot["xy"] = z["xy"][x0:x0 + F * B].reshape(F, B, 2)
shot["covered"] = z["covered"][x0:x0 + F * B].reshape(F, B) \
.astype(bool)
shot["img_diff"] = z["img_diff"][f0:f0 + F]
shot["ball_ids"] = z["ball_ids"][b0:b0 + B]
yield shot
# ------------------------------------------------------------ worker/main
def shard_path(out_dir, split: int, start: int) -> Path:
tag = "train" if split == SPLIT_TRAIN else "heldout"
return Path(out_dir) / f"{tag}_{start:07d}.npz"
def gen_shard(args_tuple):
"""Worker: sinh [start, start+count) của ``split`` → 1 shard riêng
(mỗi worker ghi file mình — bẫy quen #6, không share handle)."""
split, start, count, out_dir = args_tuple
pt, place_random = _sim_env()
table = pt.Table.default()
metas, arrays = [], []
t0 = time.time()
for k in range(start, start + count):
m, a = gen_one(pt, place_random, table, k, split)
metas.append(m)
arrays.append(a)
path = shard_path(out_dir, split, start)
write_shard(path, metas, arrays)
return (path.stem, start, count, time.time() - t0,
float(np.mean([m["identifiable"] for m in metas])))
def build_spec(n_train: int, n_holdout: int, table_w: float, table_l: float,
ball_r: float, pooltool_version: str) -> dict:
try:
commit = subprocess.run(
["git", "-C", str(ROOT), "rev-parse", "HEAD"],
capture_output=True, text=True, check=True).stdout.strip()
except Exception:
commit = "unknown"
return {
"name": "bb9_synth", "created": "2026-08-11", "commit": commit,
"base_seed": BASE_SEED,
"split_rule": "rng per shot = default_rng([base_seed, split, idx]); "
"split 0=train idx<n_train, 1=heldout idx<n_holdout",
"n_train": n_train, "n_holdout": n_holdout,
"table": {"w_m": table_w, "l_m": table_l, "ball_r_m": ball_r,
"source": "pooltool Table.default() — KHONG chinh physics"},
"pooltool": pooltool_version,
"action": {"phi_deg": list(PHI_RANGE), "v0_mps": list(V0_RANGE),
"ab_max": AB_MAX,
"v0_note": "env RL [0.5,4.0]; BRIEF noi sample [0.5,8]"},
"board": {"n_object_balls": list(N_BALLS_RANGE),
"generator": "eval_fullrack._place_random + subset 1..8 "
"+ luon bi 9"},
"noise": {
"still_sigma_m": NOISE_STILL_SIGMA_M,
"along_m_per_mps": NOISE_ALONG_M_PER_MPS,
"along_max_m": NOISE_ALONG_MAX_M, "perp_m": NOISE_PERP_M,
"v_blur_min_mps": V_BLUR_MIN_MPS,
"fps_choices": list(FPS_CHOICES),
"fps_weights": list(FPS_WEIGHTS),
"p_upconvert_30": P_UPCONVERT_30,
"dropout_max_frac": DROPOUT_MAX_FRAC,
"gap_rate_per_ball": GAP_RATE_PER_BALL,
"gap_range_s": list(GAP_RANGE_S),
"img_diff": {"per_m": IMG_DIFF_PER_M, "still": IMG_DIFF_STILL,
"noise": IMG_DIFF_NOISE, "dup": IMG_DIFF_DUP},
"source": "HANDOFF 23 Bat ngo 1-3 (P0 do that); fit chi tiet "
"xem docstring gen_synth_shots.py"},
"strike_physics_probe": {
"v_ball_over_v0": "2/(1.3 + 2.5*(a^2+b^2)) — do probe BG26b "
"(center 1.5385, |a|=|b|=0.4 -> 0.9524)",
"squirt_deg_at_a04": 1.59,
"side_sign": "a>0 -> side-L, a<0 -> side-R (probe han2005 "
"settle-chord, khop _read_spin docstring)"},
"still_range_s": list(STILL_RANGE_S), "sim_max_s": SIM_MAX_S,
"format": "npz shard; xem docstring gen_synth_shots.py (PTS = "
"arange(n)/fps, khong luu)",
}
def run_sanity(out_dir: Path, split_tag: str, n_sheet: int = 20) -> dict:
"""Sanity G-26.3: histogram 4 tham số + tỷ lệ identifiable + contact
sheet 20 cú (PNG trong ``sanity/``). Trả dict số để in/ghi HANDOFF."""
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
shards = sorted(out_dir.glob(f"{split_tag}_*.npz"))
cols = {k: [] for k in ("label_v0", "label_phi", "label_a", "label_b",
"identifiable", "fps", "upconvert", "n_frames",
"scratch")}
for p in shards:
z = np.load(p)
for k in cols:
cols[k].append(z[k])
cols = {k: np.concatenate(v) for k, v in cols.items()}
sane = out_dir / "sanity"
sane.mkdir(exist_ok=True)
fig, axes = plt.subplots(2, 2, figsize=(10, 7))
for ax, key, rng_ in zip(
axes.flat, ["label_v0", "label_phi", "label_a", "label_b"],
[V0_RANGE, PHI_RANGE, (-AB_MAX, AB_MAX), (-AB_MAX, AB_MAX)]):
ax.hist(cols[key], bins=60, range=rng_)
ax.set_title(f"{key} ({split_tag})")
fig.tight_layout()
fig.savefig(sane / f"hist_labels_{split_tag}.png", dpi=110)
plt.close(fig)
# contact sheet: 20 cú đầu shard đầu — track cue bẩn + bi khác + mép bàn
fig, axes = plt.subplots(4, 5, figsize=(15, 13))
for ax, shot in zip(axes.flat, iter_shots(shards[0])):
xy, cov = shot["xy"], shot["covered"]
for b_i in range(xy.shape[1]):
m = cov[:, b_i]
ax.plot(xy[m, b_i, 0], xy[m, b_i, 1],
".-" if b_i == 0 else ".", ms=2, lw=0.7,
alpha=0.9 if b_i == 0 else 0.45)
ax.add_patch(plt.Rectangle((0, 0), 0.9906, 1.9812, fill=False,
lw=0.8))
ax.set_title(f"#{shot['shot_idx']} v0={shot['label_v0']:.1f} "
f"fps={shot['fps']}{'+dup' if shot['upconvert'] else ''}"
f" drop={shot['dropout_frac']:.2f}", fontsize=7)
ax.set_aspect("equal")
ax.set_xticks([])
ax.set_yticks([])
fig.tight_layout()
fig.savefig(sane / f"contact_sheet_{split_tag}.png", dpi=110)
plt.close(fig)
fps_mix = {int(f): int((cols["fps"] == f).sum())
for f in np.unique(cols["fps"])}
return {"n": int(len(cols["label_v0"])),
"identifiable_rate": float(cols["identifiable"].mean()),
"scratch_rate": float(cols["scratch"].mean()),
"upconvert_rate": float(cols["upconvert"].mean()),
"fps_mix": fps_mix,
"v0_minmax": [float(cols["label_v0"].min()),
float(cols["label_v0"].max())],
"frames_mean": float(cols["n_frames"].mean())}
def verify_repro(out_dir: Path, n: int = 100) -> bool:
"""G-26.3: sinh lại ``n`` cú đầu train từ spec (in-process) và so BIT
với shard đã ghi."""
pt, place_random = _sim_env()
table = pt.Table.default()
shard = sorted(out_dir.glob("train_*.npz"))[0]
fresh_m, fresh_a = [], []
for i in range(n):
m, a = gen_one(pt, place_random, table, i, SPLIT_TRAIN)
fresh_m.append(m)
fresh_a.append(a)
for i, shot in enumerate(iter_shots(shard)):
if i >= n:
break
m, a = fresh_m[i], fresh_a[i]
if not (np.array_equal(a["xy"].astype(np.float32), shot["xy"])
and np.array_equal(a["covered"], shot["covered"])
and np.array_equal(a["img_diff"].astype(np.float32),
shot["img_diff"])
and m["label_v0"] == np.float32(shot["label_v0"])):
print(f" MISMATCH tai cu {i}")
return False
return True
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--out", default=r"D:\Khoa luan\datasets\bb9_synth")
ap.add_argument("--train", type=int, default=150_000)
ap.add_argument("--holdout", type=int, default=5_000)
ap.add_argument("--workers", type=int, default=10)
ap.add_argument("--sanity", action="store_true")
ap.add_argument("--verify", type=int, default=0,
help="so bit N cu dau train voi shard (G-26.3)")
ap.add_argument("--only", choices=["train", "heldout", "all"],
default="all")
args = ap.parse_args()
out_dir = Path(args.out)
out_dir.mkdir(parents=True, exist_ok=True)
if args.verify:
ok = verify_repro(out_dir, args.verify)
print(f"verify {args.verify} cu dau: {'BIT-GIONG' if ok else 'LECH'}")
sys.exit(0 if ok else 1)
tasks = []
if args.only in ("train", "all"):
tasks += [(SPLIT_TRAIN, s, min(SHARD_SIZE, args.train - s),
str(out_dir))
for s in range(0, args.train, SHARD_SIZE)]
if args.only in ("heldout", "all"):
tasks += [(SPLIT_HELDOUT, s, min(SHARD_SIZE, args.holdout - s),
str(out_dir))
for s in range(0, args.holdout, SHARD_SIZE)]
# resume idempotent: shard đã có thì bỏ qua — nội dung mỗi cú tất định
# theo [BASE_SEED, split, idx] nên sinh lại hay giữ nguyên là một
n_all = len(tasks)
tasks = [t for t in tasks if not shard_path(t[3], t[0], t[1]).exists()]
if len(tasks) < n_all:
print(f"resume: bo qua {n_all - len(tasks)} shard da co")
pt, _pr = _sim_env()
import pooltool
spec = build_spec(args.train, args.holdout,
float(pt.Table.default().w),
float(pt.Table.default().l), 0.028575,
getattr(pooltool, "__version__", "?"))
(out_dir / "spec.json").write_text(json.dumps(spec, indent=2),
encoding="utf-8")
SPEC_GIT_COPY.write_text(json.dumps(spec, indent=2), encoding="utf-8")
print(f"spec ghi: {out_dir / 'spec.json'} + {SPEC_GIT_COPY}")
t0 = time.time()
if args.workers <= 1:
results = [gen_shard(t) for t in tasks]
else:
import multiprocessing as mp
with mp.get_context("spawn").Pool(args.workers) as pool:
results = []
for r in pool.imap_unordered(gen_shard, tasks):
results.append(r)
stem, _start, count, dt, ident = r
print(f" shard {stem}: {count} cu / {dt:.0f}s "
f"(identifiable {ident:.1%})", flush=True)
total = sum(r[2] for r in results)
print(f"XONG {total} cu / {(time.time() - t0) / 60:.1f} phut")
if args.sanity:
for tag in ("train", "heldout"):
if list(out_dir.glob(f"{tag}_*.npz")):
s = run_sanity(out_dir, tag)
print(f"sanity {tag}: {json.dumps(s)}")
if __name__ == "__main__":
main()