Spaces:
Sleeping
Sleeping
| """Đề 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 | |