Spaces:
Sleeping
Sleeping
File size: 8,246 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 | """Camera từ homography + bù độ cao tâm bi — BG31 (13/08/2026).
Production hoá kết luận BG30 (bb9_diag30_offset_fit.csv, commit f763db7):
track/detections là ảnh của TÂM BI (cao R trên vải) chiếu qua homography
MẶT VẢI z=0, nên vị trí map lệch khỏi chân tâm bi một vec-tơ hướng RA XA
chân camera: X' = P + k·(P − C_xy), k = R/(h − R). Mô hình KHỚP trên nhóm
offset ≥ 15mm (dư 2.9mm, 10 cú pilot; camera decompose chụm h = 2.410
± 0.021 m, f ≈ 2236 px).
- ``decompose_homography`` — port NGUYÊN thuật toán ``decompose_h`` từ
``scripts/broadcast/diag_cushion_offset.py`` (BG30 bước 2): ước camera
từ MỘT homography ảnh→bàn, tự ước f từ ràng buộc trực giao r1⊥r2 và
‖r1‖=‖r2‖, principal point danh định giữa ảnh.
- ``HeightCompensation`` — bù bằng nghiệm ngược ĐÓNG của mô hình trên:
P = (X' + k·C_xy)/(1 + k) — chính công thức đã kiểm offline ở
``scripts/broadcast/diag_height_compensation.py`` (BG30 bước 3).
Camera lấy từ decompose homography CỦA CHÍNH CLIP, không dùng hằng
chung; decompose thất bại / camera vô lý → KHÔNG bù (identity) + meta
``on=False`` — thà không bù còn hơn bù bằng camera rác (BRIEF 31).
Thuần numpy — venv app import được (nếp poolcoach_cv, unit test không cần
cv2/torch).
"""
from __future__ import annotations
import math
import numpy as np
# Biên "camera vô lý" cho fallback — SIẾT ở BG32 (13/08/2026): biên rộng
# BG31 (h 0.5–20m, f 200–20000px) đã cho lọt ca rác cú 11 smoke lần 1
# (h=0.686m, f=12767px) → bù bằng camera rác. Căn cứ biên mới: 19 decompose
# lành trải h 2.33–2.51m / f 2155–3012px — chừa dư ~2 lần mỗi phía để không
# chặn oan camera broadcast lạ, nhưng ca rác trên phải bị chặn.
CAM_H_MIN_M = 1.5
CAM_H_MAX_M = 6.0
CAM_F_MIN_PX = 800.0
CAM_F_MAX_PX = 8000.0
def decompose_homography(H: np.ndarray,
pp: tuple[float, float] = (960.0, 540.0)
) -> dict | None:
"""Ước camera từ MỘT homography ảnh→bàn, không cần offset field.
G = H⁻¹ (bàn→ảnh) ≅ K[r1 r2 t]; tự ước f từ ràng buộc r1⊥r2 và
‖r1‖=‖r2‖ (principal point danh định ``pp`` — giữa ảnh). Trả
``{f_px, cx, cy, h}`` hệ bàn CỦA CÚ, hoặc None nếu nghiệm f không
dương. Port nguyên từ ``diag_cushion_offset.decompose_h`` (BG30).
"""
G = np.linalg.inv(np.asarray(H, dtype=np.float64))
G = G / G[2, 2]
a, b = G[:, 0].copy(), G[:, 1].copy()
a[0] -= pp[0] * a[2]
a[1] -= pp[1] * a[2]
b[0] -= pp[0] * b[2]
b[1] -= pp[1] * b[2]
# sau khi khử principal point: m_i = (u_i/f, v_i/f, w_i) — hai ràng buộc
# trên f²: u1u2 + v1v2 + f²w1w2 = 0 ; (u1²+v1²) − (u2²+v2²) = f²(w2²−w1²)
f2 = []
den = a[2] * b[2]
if abs(den) > 1e-15:
f2.append(-(a[0] * b[0] + a[1] * b[1]) / den)
den = b[2] ** 2 - a[2] ** 2
if abs(den) > 1e-15:
f2.append(((a[0] ** 2 + a[1] ** 2) - (b[0] ** 2 + b[1] ** 2)) / den)
f2 = [v for v in f2 if v > 1e3]
if not f2:
return None
f = math.sqrt(float(np.mean(f2)))
Kinv = np.array([[1 / f, 0, -pp[0] / f], [0, 1 / f, -pp[1] / f],
[0, 0, 1.0]])
M = Kinv @ G
lam = 2.0 / (np.linalg.norm(M[:, 0]) + np.linalg.norm(M[:, 1]))
r1, r2, tvec = lam * M[:, 0], lam * M[:, 1], lam * M[:, 2]
r3 = np.cross(r1, r2)
Rm = np.stack([r1, r2, r3], axis=1)
cam = -Rm.T @ tvec # X_cam = Rm·X_table + t ⇒ C_table = −Rmᵀ t
if cam[2] < 0: # nghiệm λ âm — lật dấu (camera phải ở TRÊN mặt bàn)
cam = -cam
return {"f_px": round(f, 1), "cx": float(cam[0]), "cy": float(cam[1]),
"h": float(cam[2])}
class HeightCompensation:
"""Bù độ cao tâm bi cho toạ độ đã map qua homography mặt vải.
``on=True``: ``apply`` giải ngược P = (X' + k·C_xy)/(1 + k) với
k = R/(h − R) — camera (C_xy, h) từ decompose homography của chính
clip. ``on=False`` (fallback): ``apply`` là identity, ``meta()`` mang
cờ ``on: False`` (+ ``reason``) để JSON job khai rõ track sinh ra
dưới quy ước CHƯA bù (BRIEF 31 bước 1.4 — không áp hồi tố, hai thời
kỳ phân biệt bằng chính cờ này).
"""
def __init__(self, on: bool, cam_xy=None, h_m: float | None = None,
f_px: float | None = None, reason: str | None = None,
ball_r: float = 0.0) -> None:
self.on = bool(on)
self.cam_xy = (None if cam_xy is None
else np.asarray(cam_xy, dtype=np.float64))
self.h_m = h_m
self.f_px = f_px
self.reason = reason
self._k = (ball_r / (h_m - ball_r)) if self.on else 0.0
@classmethod
def from_homography(cls, H: np.ndarray, image_wh: tuple[float, float],
ball_r: float) -> "HeightCompensation":
"""Dựng bộ bù từ homography ảnh→bàn CỦA CHÍNH CLIP.
``image_wh`` = (rộng, cao) pixel của frame mà corners được chấm —
principal point danh định giữa ảnh (quy ước decompose BG30).
Mọi đường thất bại đều trả bộ bù ``on=False`` kèm ``reason``,
không ném — bù là tầng phụ, không được giết analyze.
"""
w_px, h_px = float(image_wh[0]), float(image_wh[1])
if not (w_px > 0 and h_px > 0):
return cls(False, reason="không biết kích thước frame")
try:
dec = decompose_homography(np.asarray(H, dtype=np.float64),
pp=(w_px / 2.0, h_px / 2.0))
except np.linalg.LinAlgError:
return cls(False, reason="homography suy biến (không nghịch đảo "
"được)")
if dec is None:
return cls(False, reason="decompose thất bại (nghiệm f² không "
"dương)")
h_m, f_px = float(dec["h"]), float(dec["f_px"])
cx, cy = float(dec["cx"]), float(dec["cy"])
if not all(math.isfinite(v) for v in (h_m, f_px, cx, cy)):
return cls(False, reason="decompose ra giá trị không hữu hạn")
if not (CAM_H_MIN_M <= h_m <= CAM_H_MAX_M):
return cls(False, cam_xy=(cx, cy), h_m=round(h_m, 3),
f_px=round(f_px, 1),
reason=f"h = {h_m:.2f} m ngoài "
f"[{CAM_H_MIN_M:g}, {CAM_H_MAX_M:g}] m")
if not (CAM_F_MIN_PX <= f_px <= CAM_F_MAX_PX):
return cls(False, cam_xy=(cx, cy), h_m=round(h_m, 3),
f_px=round(f_px, 1),
reason=f"f = {f_px:.0f} px ngoài "
f"[{CAM_F_MIN_PX:g}, {CAM_F_MAX_PX:g}] px")
return cls(True, cam_xy=(cx, cy), h_m=h_m, f_px=f_px, ball_r=ball_r)
def apply(self, pts: np.ndarray) -> np.ndarray:
"""X' (toạ độ map mặt vải, (2,) hay (N,2)) → chân tâm bi thật P."""
pts = np.asarray(pts, dtype=np.float64)
if not self.on:
return pts
return (pts + self._k * self.cam_xy) / (1.0 + self._k)
def meta(self) -> dict:
"""Khối ``height_comp`` cho JSON job (BRIEF 31 bước 1.4).
BG32 thêm ``cx_m``/``cy_m`` (chân camera trên hệ bàn) — optional
như ``h_m``/``f_px``, có mặt cả khi fallback vì camera ngoài biên
(để truy vết decompose rác + tích luỹ ca cho câu hỏi apex).
"""
out: dict = {"on": self.on}
if self.h_m is not None:
out["h_m"] = round(float(self.h_m), 3)
if self.f_px is not None:
out["f_px"] = round(float(self.f_px), 1)
if self.cam_xy is not None:
out["cx_m"] = round(float(self.cam_xy[0]), 3)
out["cy_m"] = round(float(self.cam_xy[1]), 3)
if self.reason is not None:
out["reason"] = self.reason
return out
|