# -*- 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 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()