poolcoach / src /poolcoach_cv /autocorner.py
masterdanh's picture
deploy: snapshot for HF Space
78738de
Raw
History Blame Contribute Delete
24 kB
"""Đề xuất 4 góc bàn từ frame đầu — bỏ bước bắt buộc click tay (14/08/2026).
Thuần cv2 + numpy, KHÔNG train gì (BRIEF 14/08 việc A): dò mặt vải theo màu
trội (HSV) → thành phần liên thông lớn nhất → bao lồi → rút về tứ giác →
xếp 4 góc theo quy ước ``homography.table_corners()``.
**Van bắt buộc** (BRIEF): decompose homography của quad đề xuất qua đúng
đường ``camera.HeightCompensation.from_homography`` — h ∉ [1.5, 6] m hoặc
f ∉ [800, 8000] px (biên BG32) thì coi như đề xuất HỎNG: trả
``ok=False`` + lý do, người dùng quay về luồng click tay. Biên KHÔNG nới ở
đây; module này không sở hữu ngưỡng nào của van.
Vì sao rút bao lồi về tứ giác bằng cách BỎ CẠNH (giao hai cạnh kề) chứ
không lấy đỉnh hull: góc pocket của bàn bị vạt (miệng lỗ), nên biên vải là
một đa giác 8 cạnh — đỉnh hull nằm ở MÉP vạt, còn điểm người ta chấm tay là
"giao 2 mép nose vải", tức GIAO của hai cạnh băng kéo dài. Bỏ cạnh vạt =
dựng đúng giao điểm đó.
Cặp băng ngắn — hạn chế đã biết, đọc kỹ trước khi tin
------------------------------------------------------
Quy ước corners bắt 2 điểm đầu là một BĂNG NGẮN. Xác định cặp nào ngắn
bằng hình học thuần (vanishing point + f từ ràng buộc trực giao) ĐÃ THỬ và
KHÔNG dùng được trên footage broadcast pilot: hai ước lượng f² của
``decompose_homography`` lệch nhau 2–4 lần (763 vs 2951 px ở cú 1), nên tỉ
lệ cạnh suy ra ~0.95 thay vì 0.5 — mô hình "principal point giữa ảnh, pixel
vuông, không méo" không đủ khớp camera này để phán tỉ lệ. Van sanity cũng
KHÔNG tự phân biệt: gán sai cặp vẫn lọt van ở 8/10 cú pilot.
Nên ở đây dùng HEURISTIC ``BẤT ĐỐI XỨNG ĐỘ DÀI``: cặp cạnh đối nào lệch độ
dài nhiều hơn là cặp BĂNG NGẮN (camera broadcast đứng dọc trục dài bàn nên
hai băng ngắn ở hai độ sâu rất khác nhau — 852 vs 559 px ở cú 1 — còn hai
băng dài gần bằng nhau, 602 vs 599). Đúng 10/10 trên pilot, nhưng pilot chỉ
có MỘT camera: camera đặt bên hông bàn sẽ lật kết luận. Vì thế kết quả trả
kèm ``rails`` (số đo bất đối xứng) và FE có nút xoay nhãn — máy đoán sai cặp
thì người dùng sửa bằng một cú bấm, không phải chấm lại 4 điểm.
"""
from __future__ import annotations
import math
import numpy as np
from poolcoach_cv.camera import HeightCompensation
from poolcoach_cv.homography import (TableHomography, apply_homography,
compute_homography, orient_corners,
table_corners)
# --- dò vải theo màu trội (HSV, thang OpenCV: H 0–179, S/V 0–255) ---------
# Màu trội lấy bằng MODE 3 CHIỀU (H, S, V) chứ không chỉ hue: vải giải đấu
# broadcast đo được là xám-xanh BÃO HOÀ THẤP (10 frame pilot: H 105–107,
# S 54–60, V 178–201), trong khi phông nền/banner sau bàn CÙNG hue 106 nhưng
# bão hoà cao. Lọc theo hue thôi thì bắt trúng banner, không trúng bàn — đã
# đo, 0/11 ca qua van ở bản chỉ-hue.
WORK_MAX_DIM = 960 # ảnh làm việc. 640 ĐÃ THỬ và không đủ: băng ngắn ĐẦU
# XA của khung broadcast chỉ dày vài pixel, sai 1px ở
# đó là hàng chục cm trên bàn (đo 14/08: median lệch
# 14.7px và 2/11 ca trượt van ở 640, so với 14.5px và
# 11/11 ở 960). 1280 nhích median xuống 11.9px nhưng
# rớt 2 ca qua van — chọn 960.
CLOTH_V_MIN = 30 # vùng gần đen (bóng, lỗ) không tin màu được
CLOTH_S_MIN = 25 # vải là mặt CÓ MÀU. Không có sàn này thì một nền
# xám/đen lớn (studio tối, hộp đen quanh khung) thắng
# phiếu mode và cả tầng dò bám nhầm nền — bắt được
# bằng ảnh tổng hợp 14/08. Vải giải đo S≈56 nên sàn
# 25 không cắt vào vải.
HUE_BIN, SAT_BIN, VAL_BIN = 4, 16, 16 # lượng tử hoá khi tìm mode 3D
HUE_HALF, SAT_HALF, VAL_HALF = 8, 25, 30 # nửa cửa sổ quanh màu mode
MIN_AREA_FRAC = 0.06 # vùng vải < 6% khung → không phải bàn bi-a chiếm hình
MIN_QUAD_FILL = 0.80 # diện tích vải / diện tích tứ giác — thấp là quad ôm
# cả thứ không phải bàn (bám vào khán đài cùng màu)
CLOSE_DIV = 40 # cỡ kernel đóng lỗ = min(cạnh ảnh làm việc) / số này
# --- nắn 4 cạnh về đường băng (refine_quad) ------------------------------
REFINE_TOL_PX = 2.0 # inlier RANSAC quanh đường băng (ảnh làm việc)
REFINE_ROUNDS = 2
MIN_EDGE_COS = 0.985 # |cos| pháp tuyến mới vs cũ (~10°) — thấp hơn là
# RANSAC bám nhầm vật khác, bỏ riêng cạnh đó
MAX_SHIFT_FRAC = 0.15 # đỉnh dịch quá 15% cạnh trung bình ⇒ vòng nắn hỏng
AREA_KEEP = (0.60, 1.20) # diện tích sau nắn phải nằm trong dải này
# Bàn giải broadcast (poolcoach_cv.broadcast.TABLE_*_M) — van BG32 đo trên
# đúng hệ mét này, nên mặc định phải là nó, không phải bàn pooltool 9ft.
TABLE_W_M = 1.27
TABLE_L_M = 2.54
BALL_R_M = 0.028575
RAIL_CLOTH_M = 0.05 # bề rộng vải phủ cushion, từ mép nose ra ngoài —
# xem ``inset_quad``. 0 = tắt hiệu chỉnh.
_EPS = 1e-9
# ------------------------------------------------------------- hình học
def _line_intersect(p1, p2, p3, p4):
"""Giao ĐƯỜNG THẲNG (p1p2) × (p3p4) — None khi song song."""
d1, d2 = p2 - p1, p4 - p3
den = d1[0] * d2[1] - d1[1] * d2[0]
if abs(den) < _EPS:
return None
t = ((p3[0] - p1[0]) * d2[1] - (p3[1] - p1[1]) * d2[0]) / den
return p1 + t * d1
def _tri_area(a, b, c) -> float:
return abs((b[0] - a[0]) * (c[1] - a[1])
- (c[0] - a[0]) * (b[1] - a[1])) / 2.0
def _poly_area(p: np.ndarray) -> float:
x, y = p[:, 0], p[:, 1]
return abs(float(np.dot(x, np.roll(y, -1)) - np.dot(np.roll(x, -1), y))) / 2
def reduce_to_quad(poly: np.ndarray) -> np.ndarray | None:
"""Đa giác LỒI (N,2) → tứ giác, bỏ dần cạnh rẻ nhất.
Mỗi bước: thử bỏ từng cạnh (thay hai đỉnh của nó bằng giao của hai cạnh
kề), chọn cạnh làm PHÌNH diện tích ít nhất. Đây là phép rút chuẩn cho
"tứ giác nhỏ nhất bao đa giác lồi" — đúng thứ cần: cạnh vạt ở miệng lỗ
rất ngắn nên bị bỏ trước, và giao hai cạnh băng chính là điểm chấm tay.
Trả None khi không rút được (mọi cặp cạnh kề song song).
"""
P = [np.asarray(p, dtype=np.float64) for p in poly]
if len(P) < 4:
return None
while len(P) > 4:
n = len(P)
best = None
for i in range(n):
prev_a, prev_b = P[(i - 1) % n], P[i]
next_a, next_b = P[(i + 1) % n], P[(i + 2) % n]
X = _line_intersect(prev_a, prev_b, next_a, next_b)
if X is None:
continue
cost = _tri_area(P[i], X, P[(i + 1) % n])
if best is None or cost < best[0]:
best = (cost, i, X)
if best is None:
return None
_, i, X = best
R = P[i:] + P[:i] # đưa cạnh cần bỏ về đầu: (R[0], R[1])
P = [X] + R[2:]
return np.asarray(P, dtype=np.float64)
def _fit_line(pts: np.ndarray) -> tuple[np.ndarray, float]:
"""Đường khớp bình phương tối thiểu TOÀN PHƯƠNG (TLS/PCA) qua ``pts``.
Trả (pháp tuyến đơn vị n, c) với n·x = c."""
c = pts.mean(axis=0)
u, s, vt = np.linalg.svd(pts - c)
n = vt[1] # hướng phương sai NHỎ nhất = pháp tuyến
return n, float(np.dot(n, c))
def _ransac_line(pts: np.ndarray, tol_px: float, span: float
) -> tuple[np.ndarray, float] | None:
"""Đường "đông phiếu nhất" qua đám điểm biên của MỘT băng.
Vì sao không dùng thẳng bao lồi: biên vải lồi lõm (miệng lỗ góc, lỗ
giữa, gậy/tay thò vào) — bao lồi bám các chỗ PHỒNG nên tứ giác nở ra
~20% (đo 14/08 trên 10 frame pilot: blob/mặt-bàn 1.04 mà quad/mặt-bàn
1.20). Băng bàn là đoạn thẳng DÀI, chỗ phồng/lõm thì ngắn, nên đếm
phiếu inlier tách được đường băng thật.
TẤT ĐỊNH (không random): duyệt mọi cặp trong tập con lấy đều ≤ 60 điểm,
chỉ nhận cặp cách nhau > 0.3 chiều dài cạnh (cặp gần nhau cho hướng
nhiễu). Trả (n, c) đã refit TLS trên inlier, hoặc None nếu quá ít điểm.
"""
if len(pts) < 8:
return None
step = max(1, len(pts) // 60)
sub = pts[::step]
d = sub[:, None, :] - sub[None, :, :]
long_enough = np.linalg.norm(d, axis=2) > 0.3 * span
ii, jj = np.nonzero(np.triu(long_enough, 1))
if not len(ii):
return None
dirs = sub[jj] - sub[ii]
dirs = dirs / np.linalg.norm(dirs, axis=1, keepdims=True)
nrm = np.stack([-dirs[:, 1], dirs[:, 0]], axis=1) # (M, 2)
cc = np.einsum("ij,ij->i", nrm, sub[ii]) # (M,)
resid = np.abs(pts @ nrm.T - cc[None, :]) # (N, M)
votes = (resid <= tol_px).sum(axis=0)
best = int(np.argmax(votes))
inl = pts[resid[:, best] <= tol_px]
if len(inl) < 8:
return None
return _fit_line(inl)
def refine_quad(contour: np.ndarray, quad: np.ndarray,
tol_px: float = REFINE_TOL_PX,
rounds: int = REFINE_ROUNDS) -> np.ndarray | None:
"""Nắn 4 đỉnh tứ giác về GIAO của 4 đường băng khớp từ chính biên vải.
Mỗi vòng: gán điểm biên cho cạnh gần nhất (chiếu rơi trong đoạn) →
RANSAC một đường cho mỗi cạnh → giao 4 đường. Cạnh nào không đủ điểm
thì GIỮ đường cũ — thà giữ ước lượng thô còn hơn bịa.
**Chốt an toàn**: vòng nào cho tứ giác nhảy quá xa bản thô (đỉnh dịch
> ``MAX_SHIFT_FRAC`` cạnh trung bình, hoặc diện tích ra ngoài
``AREA_KEEP``) thì HUỶ vòng đó và trả bản trước. Đã bắt được ca thật
(cú 7 pilot 14/08: một cạnh RANSAC bám nhầm cây gậy → tứ giác văng ra
(242,-11)-(553,579)). Nắn là bước tinh chỉnh, không được quyền phá.
"""
q = np.asarray(quad, dtype=np.float64)
pts = np.asarray(contour, dtype=np.float64)
for _ in range(rounds):
lines, spans, ts = [], [], []
for i in range(4):
a, b = q[i], q[(i + 1) % 4]
ln = float(np.linalg.norm(b - a))
if ln < _EPS:
return None
u = (b - a) / ln
lines.append((np.array([-u[1], u[0]]),
float(np.dot(np.array([-u[1], u[0]]), a))))
spans.append(ln)
ts.append(((pts - a) @ u) / ln) # vị trí chiếu trên cạnh
t = np.stack(ts, axis=1)
dist = np.stack([np.abs(pts @ lines[i][0] - lines[i][1])
for i in range(4)], axis=1)
dist = np.where((t >= -0.02) & (t <= 1.02), dist, np.inf)
owner = np.argmin(dist, axis=1)
new = []
for i in range(4):
got = _ransac_line(pts[owner == i], tol_px, spans[i])
# chốt an toàn PER-CẠNH: đường mới lệch góc quá nhiều so với
# cạnh thô là RANSAC đã bám nhầm vật khác (gậy, tay, mép banner)
# — bỏ riêng cạnh đó, ba cạnh còn lại vẫn được nắn
if got is not None:
cosang = abs(float(np.dot(got[0], lines[i][0])))
if cosang < MIN_EDGE_COS:
got = None
new.append(got if got is not None else lines[i])
out = []
for i in range(4):
n1, c1 = new[(i - 1) % 4]
n2, c2 = new[i]
A = np.stack([n1, n2])
if abs(np.linalg.det(A)) < 1e-9:
return None
out.append(np.linalg.solve(A, np.array([c1, c2])))
# đỉnh i = giao cạnh (i-1) và cạnh i — cùng thứ tự vòng với q
cand = np.asarray(out, dtype=np.float64)
shift = float(np.linalg.norm(cand - q, axis=1).max())
a_old, a_new = _poly_area(q), _poly_area(cand)
if (shift > MAX_SHIFT_FRAC * float(np.mean(spans))
or not (AREA_KEEP[0] * a_old <= a_new <= AREA_KEEP[1] * a_old)):
return q # vòng này phá hình — giữ bản trước
q = cand
return q
def inset_quad(corners: np.ndarray, d_m: float, table_w: float,
table_l: float) -> np.ndarray:
"""Thụt tứ giác BIÊN VẢI vào ``d_m`` mét mỗi phía → 4 góc mép NOSE.
Vải phủ trùm mặt cushion nên biên vải nằm NGOÀI mép nose (điểm người
dùng chấm tay) một khoảng bằng bề rộng vải phủ cushion — hằng VẬT LÝ của
bàn, không phải hệ số fit theo camera. Đo trên 9 frame pilot 14/08 (sau
bước nắn cạnh): biên vải nằm ngoài mép nose 4.3–5.1 cm ở băng dài và
4.5–5.6 cm ở băng đối diện — khớp bề rộng vải phủ cushion bàn giải.
Cách thụt phải làm TRONG hệ bàn chứ không phải trong ảnh: dựng
homography quad → hình chữ nhật (W+2d)×(L+2d), lấy 4 góc của hình chữ
nhật W×L đặt lệch d, map ngược về pixel. Thụt thẳng trong ảnh sẽ sai vì
phối cảnh (5cm ở đầu xa chỉ vài pixel, ở đầu gần vài chục).
"""
c = np.asarray(corners, dtype=np.float64)
if d_m <= 0:
return c
H = compute_homography(c, table_corners(table_w + 2 * d_m,
table_l + 2 * d_m))
inner = table_corners(table_w, table_l) + d_m
return apply_homography(np.linalg.inv(H), inner)
def order_quad(quad: np.ndarray) -> tuple[np.ndarray, dict]:
"""Xếp 4 góc theo quy ước ``table_corners()`` — 2 điểm đầu = BĂNG NGẮN.
Cặp băng ngắn chọn bằng bất đối xứng độ dài (xem docstring module);
chiều vòng do ``orient_corners`` chuẩn hoá nốt. Trả (corners, thông tin
chọn cặp) — số đo trả ra để người đọc tự kiểm, không giấu heuristic.
"""
q = np.asarray(quad, dtype=np.float64)
seg = [float(np.linalg.norm(q[(i + 1) % 4] - q[i])) for i in range(4)]
asym = [max(seg[i], seg[i + 2]) / max(min(seg[i], seg[i + 2]), _EPS)
for i in (0, 1)]
swapped = asym[1] > asym[0]
if swapped:
q = q[[1, 2, 3, 0]]
return orient_corners(q), {
"len_px": [round(v, 1) for v in seg],
"asym_pair0": round(asym[0], 3), "asym_pair1": round(asym[1], 3),
"swapped": bool(swapped),
}
# ------------------------------------------------------ dò vải (cần cv2)
def cloth_mask(img_bgr: np.ndarray) -> tuple[np.ndarray, dict]:
"""Mặt nạ mặt vải theo màu trội (mode 3D trong HSV) + thông tin dò.
Ném ImportError nếu thiếu cv2 (caller quyết: route trả 503, script dừng).
"""
import cv2
hsv = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HSV)
h = hsv[:, :, 0].astype(np.int16)
s = hsv[:, :, 1].astype(np.int16)
v = hsv[:, :, 2].astype(np.int16)
ok = (v >= CLOTH_V_MIN) & (s >= CLOTH_S_MIN)
if not ok.any():
return np.zeros(h.shape, np.uint8), {"hue": None, "sat": None,
"val": None, "mode_frac": 0.0}
nb_s, nb_v = 256 // SAT_BIN, 256 // VAL_BIN
idx = ((h[ok] // HUE_BIN) * nb_s * nb_v + (s[ok] // SAT_BIN) * nb_v
+ (v[ok] // VAL_BIN)).astype(np.int64)
cnt = np.bincount(idx, minlength=(180 // HUE_BIN + 1) * nb_s * nb_v)
top = int(np.argmax(cnt))
sel = idx == top
# tâm cửa sổ = TRUNG VỊ trong bin trội, không phải tâm bin — bin thô
# (4/16/16) chỉ để tìm cụm, còn ngưỡng thì bám màu thật
h0 = int(np.median(h[ok][sel]))
s0 = int(np.median(s[ok][sel]))
v0 = int(np.median(v[ok][sel]))
dh = np.abs(h - h0)
dh = np.minimum(dh, 180 - dh) # khoảng cách vòng tròn
mask = ((dh <= HUE_HALF) & (np.abs(s - s0) <= SAT_HALF)
& (np.abs(v - v0) <= VAL_HALF) & ok).astype(np.uint8) * 255
return mask, {"hue": h0, "sat": s0, "val": v0,
"mode_frac": round(float(cnt[top]) / float(ok.sum()), 3)}
def _largest_blob(mask: np.ndarray, ksize: int):
"""Đóng lỗ (bi, vạch, người) → thành phần liên thông lớn nhất → contour
ngoài. Trả (contour (N,2), diện tích px) hoặc (None, 0)."""
import cv2
ker = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (ksize, ksize))
m = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, ker)
m = cv2.morphologyEx(m, cv2.MORPH_OPEN,
cv2.getStructuringElement(
cv2.MORPH_ELLIPSE,
(max(3, ksize // 2), max(3, ksize // 2))))
n, lab, stats, _ = cv2.connectedComponentsWithStats(m, connectivity=8)
if n <= 1:
return None, 0.0
idx = 1 + int(np.argmax(stats[1:, cv2.CC_STAT_AREA]))
blob = (lab == idx).astype(np.uint8) * 255
cnts, _ = cv2.findContours(blob, cv2.RETR_EXTERNAL,
cv2.CHAIN_APPROX_SIMPLE)
if not cnts:
return None, 0.0
c = max(cnts, key=cv2.contourArea)
return c.reshape(-1, 2).astype(np.float64), \
float(stats[idx, cv2.CC_STAT_AREA])
# ------------------------------------------------------------ cửa chính
def suggest_corners(img_bgr: np.ndarray, table_w: float = TABLE_W_M,
table_l: float = TABLE_L_M,
ball_r: float = BALL_R_M) -> dict:
"""Frame BGR (ảnh gốc) → đề xuất 4 góc bàn + kết quả van sanity.
Trả dict JSON-thuần::
{"ok": bool, # có đề xuất đáng hiện không
"corners": [[x, y]×4] | None, # pixel trên ẢNH GỐC truyền vào
"reason": str | None, # vì sao KHÔNG đề xuất (tiếng Việt)
"camera": {...} | None, # meta height_comp của quad đề xuất
"cloth": {...}, "rails": {...}} # số đo để hậu kiểm
KHÔNG bao giờ ném vì ảnh xấu — mọi đường thất bại đều ra ``ok=False``
kèm lý do, vì đây là tầng gợi ý: hỏng thì quay về click tay, không được
làm chết luồng upload (nếp height_comp: thà không bù còn hơn bù bậy).
"""
import cv2
img = np.asarray(img_bgr)
if img.ndim != 3 or img.shape[2] != 3:
return _fail("Ảnh không phải ảnh màu 3 kênh.")
H0, W0 = img.shape[:2]
if H0 < 64 or W0 < 64:
return _fail(f"Frame quá nhỏ ({W0}×{H0}px) để dò mặt bàn.")
scale = min(1.0, WORK_MAX_DIM / max(H0, W0))
work = (img if scale >= 1.0 else
cv2.resize(img, (max(1, round(W0 * scale)),
max(1, round(H0 * scale))),
interpolation=cv2.INTER_AREA))
wh, ww = work.shape[:2]
mask, cloth = cloth_mask(work)
ksize = max(3, (round(min(wh, ww) / CLOSE_DIV) | 1))
cnt, area = _largest_blob(mask, ksize)
cloth["area_frac"] = round(area / float(wh * ww), 4)
cloth["ksize"] = ksize
if cnt is None or cloth["area_frac"] < MIN_AREA_FRAC:
return _fail(f"Không thấy mảng vải đủ lớn (vùng màu trội chỉ "
f"{cloth['area_frac']:.1%} khung, cần ≥ "
f"{MIN_AREA_FRAC:.0%}).", cloth=cloth)
hull = cv2.convexHull(cnt.astype(np.float32)).reshape(-1, 2)
quad = reduce_to_quad(hull.astype(np.float64))
if quad is None or _poly_area(quad) < _EPS:
return _fail("Không rút được biên vải về tứ giác (biên quá bẹt).",
cloth=cloth)
refined = refine_quad(cnt, quad)
cloth["refined"] = refined is not None
if refined is not None and _poly_area(refined) > _EPS:
quad = refined
fill = area / max(_poly_area(quad), _EPS)
cloth["quad_fill"] = round(float(fill), 3)
if fill < MIN_QUAD_FILL:
return _fail(f"Tứ giác dò được chỉ khớp {fill:.0%} vùng vải "
f"(cần ≥ {MIN_QUAD_FILL:.0%}) — nền cùng màu bàn?",
cloth=cloth)
corners, rails = order_quad(quad / scale) # về pixel ảnh GỐC
corners = inset_quad(corners, RAIL_CLOTH_M, table_w, table_l)
cloth["touches_border"] = bool(
(cnt[:, 0] <= 1).any() or (cnt[:, 1] <= 1).any()
or (cnt[:, 0] >= ww - 2).any() or (cnt[:, 1] >= wh - 2).any())
try:
th = TableHomography(corners, table_w=table_w, table_l=table_l)
except ValueError as e:
return _fail(f"Tứ giác đề xuất không dựng được homography ({e})",
cloth=cloth, rails=rails)
hc = HeightCompensation.from_homography(th.H, (W0, H0), ball_r)
cam = hc.meta()
if not hc.on:
# ĐÚNG van BRIEF: camera vô lý ⇒ coi như đề xuất hỏng, KHÔNG hiện
return _fail(f"Van sanity chặn: camera suy từ tứ giác đề xuất vô lý "
f"({cam.get('reason', 'không rõ')}).",
cloth=cloth, rails=rails, camera=cam)
return {"ok": True,
"corners": [[round(float(x), 1), round(float(y), 1)]
for x, y in corners],
"reason": None, "camera": cam, "cloth": cloth, "rails": rails}
def _fail(reason: str, cloth=None, rails=None, camera=None) -> dict:
return {"ok": False, "corners": None, "reason": reason,
"camera": camera, "cloth": cloth or {}, "rails": rails or {}}
def suggest_from_video(video_path, t_s: float = 0.0, **kw) -> dict:
"""Tiện ích: lấy frame tại ``t_s`` của video rồi gọi ``suggest_corners``.
Dùng cho script đo (FE tự trích frame đầu bằng <video>, không qua đây)."""
import cv2
cap = cv2.VideoCapture(str(video_path))
try:
if t_s > 0:
cap.set(cv2.CAP_PROP_POS_MSEC, t_s * 1000.0)
ok, frame = cap.read()
finally:
cap.release()
if not ok:
return _fail(f"Không đọc được frame tại {t_s:g}s của {video_path}.")
return suggest_corners(frame, **kw)
def corner_errors_px(pred, truth) -> tuple[list[float], int]:
"""Lệch pixel PER-CORNER giữa hai bộ 4 góc + số nhịp xoay đã dùng.
CHỈ cho phép xoay 0 hoặc 2 nhịp: xoay 2 là chấm từ băng ngắn ĐẦU KIA của
bàn — bàn đối xứng nên hiểu bàn y hệt (ghi chú ``orient_corners``). Xoay
1/3 thì KHÔNG cho: đó là gán nhầm băng ngắn ↔ băng dài, toạ độ bi sai
hẳn; nuốt nó vào phép khớp là tự giấu lỗi nặng nhất của tầng này.
"""
p = np.asarray(pred, dtype=np.float64)
t = np.asarray(truth, dtype=np.float64)
best, rot = None, 0
for r in (0, 2):
d = np.linalg.norm(np.roll(p, -r, axis=0) - t, axis=1)
if best is None or d.sum() < best.sum():
best, rot = d, r
return [float(v) for v in best], rot