"""Đề 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