| from fastapi import FastAPI, HTTPException |
| from fastapi.middleware.cors import CORSMiddleware |
| from pydantic import BaseModel |
| import base64 |
| import os |
| import cv2 |
| import numpy as np |
| import supervision as sv |
| import urllib.request as _url_req |
| from ultralytics import YOLO |
| from PIL import Image as _PILImage, ImageDraw as _PILDraw, ImageFont as _PILFont |
| import mediapipe as mp |
| from mediapipe.tasks import python as _mp_python |
| from mediapipe.tasks.python import vision as _mp_vision |
| import gradio as gr |
|
|
| WEIGHTS_PATH = os.getenv( |
| "WEIGHTS_PATH", |
| "/Users/chihq/aituvi.vn/.model_cache/models-cache/astro-ruyvp-3-2b765a93/7eb0b5e14ed3dc7e8f0b497d53aa5d86/weights.onnx" |
| ) |
| CLASS_NAMES = ["head", "life", "love"] |
|
|
| app = FastAPI(title="Palm Line Inference API") |
|
|
| app.add_middleware( |
| CORSMiddleware, |
| allow_origins=["*"], |
| allow_methods=["POST", "GET"], |
| allow_headers=["*"], |
| ) |
| model = YOLO(WEIGHTS_PATH, task="pose") |
|
|
| |
| _MODEL_PATH = os.getenv( |
| "MEDIAPIPE_MODEL_PATH", |
| "/Users/chihq/aituvi.vn/.model_cache/hand_landmarker.task" |
| ) |
| _MODEL_URL = "https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task" |
| if not os.path.exists(_MODEL_PATH): |
| print("Đang tải hand_landmarker.task (~6 MB)...") |
| _url_req.urlretrieve(_MODEL_URL, _MODEL_PATH) |
| print("Xong!") |
|
|
| |
| _VIET_FONT_PATHS = [ |
| "/System/Library/Fonts/Supplemental/Arial Unicode.ttf", |
| "/System/Library/Fonts/Helvetica.ttc", |
| "/System/Library/Fonts/Menlo.ttc", |
| "/System/Library/Fonts/Monaco.ttf", |
| "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", |
| ] |
| _PIL_FONT_CACHE: dict = {} |
|
|
|
|
| def _pil_font(size: int): |
| if size not in _PIL_FONT_CACHE: |
| for fp in _VIET_FONT_PATHS: |
| if os.path.exists(fp): |
| try: |
| _PIL_FONT_CACHE[size] = _PILFont.truetype(fp, size) |
| break |
| except Exception: |
| pass |
| else: |
| _PIL_FONT_CACHE[size] = _PILFont.load_default() |
| return _PIL_FONT_CACHE[size] |
|
|
|
|
| def _pil_text_size(text: str, size: int): |
| font = _pil_font(size) |
| bbox = font.getbbox(text) |
| tw = bbox[2] - bbox[0] |
| asc, desc = font.getmetrics() |
| return (tw, asc), desc |
|
|
|
|
| def _put_text_pil(cv_img, text: str, x: int, y: int, color_bgr, size: int = 13): |
| font = _pil_font(size) |
| asc, _ = font.getmetrics() |
| pil = _PILImage.fromarray(cv2.cvtColor(cv_img, cv2.COLOR_BGR2RGB)) |
| draw = _PILDraw.Draw(pil) |
| draw.text((x, y - asc), text, |
| fill=(color_bgr[2], color_bgr[1], color_bgr[0]), font=font) |
| cv_img[:] = cv2.cvtColor(np.array(pil), cv2.COLOR_RGB2BGR) |
|
|
|
|
| def _put_text_centered(cv_img, text, cx, cy, color_bgr, size=10, bg_alpha=0.6): |
| fsz = _fs(size) if isinstance(size, float) else size |
| (tw, th), bl = _pil_text_size(text, fsz) |
| pad = 4 |
| tx = int(cx - tw / 2) |
| ty = int(cy + th / 2) |
| r = (tx - pad, ty - th - pad, tx + tw + pad, ty + bl + pad) |
| overlay = cv_img.copy() |
| cv2.rectangle(overlay, (r[0], r[1]), (r[2], r[3]), (0, 0, 0), -1) |
| cv_img[:] = cv2.addWeighted(overlay, bg_alpha, cv_img, 1 - bg_alpha, 0) |
| _put_text_pil(cv_img, text, tx, ty, color_bgr, fsz) |
|
|
|
|
| def _fs(scale: float) -> int: |
| return max(9, round(scale * 30)) |
|
|
|
|
| |
| def build_edge_map(img): |
| gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) |
| clahe = cv2.createCLAHE(clipLimit=3.0, tileGridSize=(8, 8)) |
| enhanced = clahe.apply(gray) |
| blurred = cv2.GaussianBlur(enhanced, (3, 3), 0) |
| return cv2.Canny(blurred, 30, 80) |
|
|
|
|
| def snap_to_edge(edge_map, x, y, perp_dx, perp_dy, search_radius=12): |
| best_x, best_y = x, y |
| best_score = -1 |
| for r in range(-search_radius, search_radius + 1): |
| nx = int(round(x + r * perp_dx)) |
| ny = int(round(y + r * perp_dy)) |
| if 0 <= nx < edge_map.shape[1] and 0 <= ny < edge_map.shape[0]: |
| score = float(edge_map[ny, nx]) - abs(r) * 1.5 |
| if score > best_score: |
| best_score = score |
| best_x, best_y = nx, ny |
| return best_x, best_y |
|
|
|
|
| def trace_line_on_edges(edge_map, pts_list, steps_per_segment=30, search_radius=12): |
| traced = [] |
| for j in range(len(pts_list) - 1): |
| x1, y1 = pts_list[j] |
| x2, y2 = pts_list[j + 1] |
| if x1 <= 0 or y1 <= 0 or x2 <= 0 or y2 <= 0: |
| continue |
| dx, dy = x2 - x1, y2 - y1 |
| length = max(np.hypot(dx, dy), 1) |
| perp_dx, perp_dy = -dy / length, dx / length |
| for step in range(steps_per_segment + 1): |
| t = step / steps_per_segment |
| gx, gy = x1 + t * dx, y1 + t * dy |
| sx, sy = snap_to_edge(edge_map, gx, gy, perp_dx, perp_dy, search_radius) |
| traced.append((sx, sy)) |
| return traced |
|
|
|
|
| def smooth_points(pts, kernel=15): |
| if len(pts) < kernel: |
| return pts |
| arr = np.array(pts, dtype=np.float64) |
| k = kernel if kernel % 2 == 1 else kernel + 1 |
| pad = k // 2 |
| xs = np.pad(arr[:, 0], pad, mode='reflect') |
| ys = np.pad(arr[:, 1], pad, mode='reflect') |
| w = np.ones(k) / k |
| sx = np.convolve(xs, w, mode='valid') |
| sy = np.convolve(ys, w, mode='valid') |
| return [(int(round(x)), int(round(y))) for x, y in zip(sx, sy)] |
|
|
|
|
| |
| def draw_dashed_line(img, p1, p2, color, thick=1, dash=8, gap=5): |
| x1, y1 = int(p1[0]), int(p1[1]) |
| x2, y2 = int(p2[0]), int(p2[1]) |
| dist = max(np.hypot(x2 - x1, y2 - y1), 1) |
| dx, dy = (x2 - x1) / dist, (y2 - y1) / dist |
| d, on = 0.0, True |
| while d < dist: |
| seg = dash if on else gap |
| nd = min(d + seg, dist) |
| if on: |
| cv2.line(img, |
| (int(x1 + d * dx), int(y1 + d * dy)), |
| (int(x1 + nd * dx), int(y1 + nd * dy)), |
| color, thick, cv2.LINE_AA) |
| d, on = nd, not on |
|
|
|
|
| def draw_bracket(img, x1, y1, x2, y2, color, size=32, thick=2): |
| for cx, cy, hd, vd in [(x1, y1, 1, 1), (x2, y1, -1, 1), |
| (x1, y2, 1, -1), (x2, y2, -1, -1)]: |
| cv2.line(img, (cx, cy), (cx + hd * size, cy), color, thick, cv2.LINE_AA) |
| cv2.line(img, (cx, cy), (cx, cy + vd * size), color, thick, cv2.LINE_AA) |
|
|
|
|
| def make_quad(lm_pts, a, b, hw): |
| p1 = np.array(lm_pts[a], dtype=np.float32) |
| p2 = np.array(lm_pts[b], dtype=np.float32) |
| d = p2 - p1 |
| L = np.linalg.norm(d) |
| if L < 1: |
| return None |
| perp = np.array([-d[1], d[0]]) / L * hw |
| return np.array([p1 + perp, p1 - perp, p2 - perp, p2 + perp], dtype=np.int32) |
|
|
|
|
| |
| def compute_line_curvature(pts): |
| if len(pts) < 3: |
| return 0.0 |
| curvs = [] |
| for i in range(1, len(pts) - 1, max(1, len(pts) // 20)): |
| a = np.array(pts[max(0, i - 5)], dtype=np.float64) |
| b = np.array(pts[i], dtype=np.float64) |
| c = np.array(pts[min(len(pts) - 1, i + 5)], dtype=np.float64) |
| ab, bc = np.linalg.norm(b - a), np.linalg.norm(c - b) |
| ca = np.linalg.norm(a - c) |
| area = abs(np.cross(b - a, c - a)) |
| denom = ab * bc * ca |
| if denom > 1: |
| curvs.append(2.0 * area / denom) |
| return float(np.mean(curvs)) if curvs else 0.0 |
|
|
|
|
| def compute_line_length_px(pts): |
| total = 0.0 |
| for i in range(1, len(pts)): |
| total += np.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]) |
| return total |
|
|
|
|
| def finger_length_px(lm_pts, base_idx, tip_idx): |
| joints = list(range(base_idx, tip_idx + 1)) |
| total = 0.0 |
| for i in range(len(joints) - 1): |
| a, b = lm_pts[joints[i]], lm_pts[joints[i + 1]] |
| total += np.hypot(a[0] - b[0], a[1] - b[1]) |
| return total |
|
|
|
|
| def classify_hand_shape(lm_pts): |
| palm_w = np.hypot(lm_pts[5][0] - lm_pts[17][0], lm_pts[5][1] - lm_pts[17][1]) |
| palm_h = np.hypot(lm_pts[0][0] - lm_pts[9][0], lm_pts[0][1] - lm_pts[9][1]) |
| mid_len = finger_length_px(lm_pts, 9, 12) |
| ratio_palm = palm_w / max(palm_h, 1) |
| ratio_finger = mid_len / max(palm_h, 1) |
| if ratio_palm > 0.75 and ratio_finger < 0.85: |
| return "ĐẤT", "Lòng vuông, ngón ngắn" |
| elif ratio_palm > 0.75 and ratio_finger >= 0.85: |
| return "THỦY", "Lòng vuông, ngón dài" |
| elif ratio_palm <= 0.75 and ratio_finger >= 0.85: |
| return "KHÍ", "Lòng hẹp, ngón dài" |
| else: |
| return "HỎA", "Lòng hẹp, ngón ngắn" |
|
|
|
|
| def thumb_angle_deg(lm_pts): |
| a = np.array(lm_pts[2], dtype=np.float64) |
| b = np.array(lm_pts[1], dtype=np.float64) |
| c = np.array(lm_pts[5], dtype=np.float64) |
| ba, bc = a - b, c - b |
| cos_a = np.dot(ba, bc) / (np.linalg.norm(ba) * np.linalg.norm(bc) + 1e-9) |
| return float(np.degrees(np.arccos(np.clip(cos_a, -1, 1)))) |
|
|
|
|
| def draw_angle_arc(img, center, p1, p2, color, radius=30, thick=1): |
| cx, cy = int(center[0]), int(center[1]) |
| a1 = np.degrees(np.arctan2(-(p1[1] - center[1]), p1[0] - center[0])) |
| a2 = np.degrees(np.arctan2(-(p2[1] - center[1]), p2[0] - center[0])) |
| if a2 < a1: |
| a1, a2 = a2, a1 |
| if a2 - a1 > 180: |
| a1, a2 = a2, a1 + 360 |
| cv2.ellipse(img, (cx, cy), (radius, radius), 0, -a2, -a1, color, thick, cv2.LINE_AA) |
|
|
|
|
| def draw_mount_ellipse(img, center, axes, angle, color, alpha=0.10): |
| layer = np.zeros_like(img, dtype=np.uint8) |
| cv2.ellipse(layer, (int(center[0]), int(center[1])), |
| (int(axes[0]), int(axes[1])), angle, 0, 360, color, -1, cv2.LINE_AA) |
| glow = cv2.GaussianBlur(layer, (0, 0), sigmaX=12) |
| img[:] = cv2.addWeighted(img, 1.0, glow, alpha, 0) |
| cv2.ellipse(img, (int(center[0]), int(center[1])), |
| (int(axes[0]), int(axes[1])), angle, 0, 360, color, 1, cv2.LINE_AA) |
|
|
|
|
| |
| class LabelPlacer: |
| def __init__(self, img_w, img_h, top_offset=0, bottom_offset=0): |
| self.placed = [] |
| self.img_w, self.img_h = img_w, img_h |
| self.top_offset = top_offset |
| self.bottom_offset = bottom_offset |
|
|
| def _rect(self, x, y, tw, th, bl, pad=8): |
| return (x - pad, y - th - pad * 2, x + tw + pad, y + bl + pad) |
|
|
| def _overlaps(self, r, margin=12): |
| for p in self.placed: |
| if not (r[2] + margin < p[0] or r[0] - margin > p[2] or |
| r[3] + margin < p[1] or r[1] - margin > p[3]): |
| return True |
| return False |
|
|
| def place(self, img, text, ax, ay, color, |
| font_scale=0.5, thickness=1, prefer_dir="auto", leader=True): |
| _pil_sz = _fs(font_scale) |
| (tw, th), bl = _pil_text_size(text, _pil_sz) |
| gap = 32 |
| dir_map = { |
| "left": [(-tw - gap, 0), (-tw - gap, -50), (-tw - gap, 50), (-tw - gap - 80, 0)], |
| "right": [(gap, 0), (gap, -50), (gap, 50), (gap + 60, 0)], |
| "above": [(-tw // 2, -55), (-tw // 2, -90), (-tw // 2 - 80, -55), (-tw // 2 + 80, -55)], |
| "below": [(-tw // 2, 55), (-tw // 2, 90), (-tw // 2 - 80, 55), (-tw // 2 + 80, 55)], |
| } |
| if prefer_dir == "auto": |
| offsets = dir_map["right"] + dir_map["left"] + dir_map["above"] + dir_map["below"] |
| else: |
| offsets = (dir_map.get(prefer_dir, []) + dir_map["right"] + |
| dir_map["left"] + dir_map["above"] + dir_map["below"]) |
|
|
| best = None |
| for dx, dy in offsets: |
| x = max(5, min(int(ax + dx), self.img_w - tw - 12)) |
| y = max(self.top_offset + th + 10, min(int(ay + dy), |
| self.img_h - self.bottom_offset - bl - 12)) |
| r = self._rect(x, y, tw, th, bl) |
| if not self._overlaps(r): |
| best = (x, y, r) |
| break |
| if best is None: |
| dx, dy = offsets[0] |
| x = max(5, min(int(ax + dx), self.img_w - tw - 12)) |
| y = max(self.top_offset + th + 10, min(int(ay + dy), |
| self.img_h - self.bottom_offset - bl - 12)) |
| best = (x, y, self._rect(x, y, tw, th, bl)) |
|
|
| x, y, r = best |
| self.placed.append(r) |
|
|
| if leader: |
| draw_dashed_line(img, (x + tw // 2, y - th // 2), |
| (int(ax), int(ay)), color, thick=1, dash=6, gap=4) |
|
|
| overlay = img.copy() |
| cv2.rectangle(overlay, (r[0], r[1]), (r[2], r[3]), (0, 0, 0), -1) |
| img[:] = cv2.addWeighted(overlay, 0.75, img, 0.25, 0) |
| cv2.rectangle(img, (r[0], r[1]), (r[2], r[3]), color, 1, cv2.LINE_AA) |
| _put_text_pil(img, text, x, y, color, _pil_sz) |
|
|
|
|
| |
| LINE_CFG = { |
| "love": {"label": "CHỈ TIM", "color": (50, 20, 255)}, |
| "head": {"label": "CHỈ TRÍ", "color": (0, 210, 255)}, |
| "life": {"label": "CHỈ SINH", "color": (20, 255, 80)}, |
| } |
| LINE_ANCHOR = { |
| "love": (0, "above"), |
| "head": (-1, "left"), |
| "life": (-1, "below"), |
| } |
|
|
| _MOUNT_DRAW = (30, 30, 30) |
| _FINGER_DRAW = (30, 30, 30) |
| _MOUNT_LABEL = (180, 170, 140) |
| _FINGER_LABEL = (180, 170, 140) |
|
|
| MOUNT_CFG = {k: _MOUNT_DRAW for k in |
| ["MỘC TINH", "THỔ TINH", "NHẬT TINH", "THỦY TINH", "KIM TINH", "NGUYỆT"]} |
|
|
| FINGER_DEFS = { |
| "CÁI": ([(1, 2), (2, 3), (3, 4)], [20, 16, 12], _FINGER_DRAW), |
| "TRỎ": ([(5, 6), (6, 7), (7, 8)], [16, 13, 10], _FINGER_DRAW), |
| "GIỮA": ([(9, 10), (10, 11), (11, 12)], [17, 13, 10], _FINGER_DRAW), |
| "ÁP ÚT": ([(13, 14), (14, 15), (15, 16)], [15, 12, 9], _FINGER_DRAW), |
| "ÚT": ([(17, 18), (18, 19), (19, 20)], [12, 9, 7], _FINGER_DRAW), |
| } |
| FINGERTIP_IDX = {4: "CÁI", 8: "TRỎ", 12: "GIỮA", 16: "ÁP ÚT", 20: "ÚT"} |
|
|
|
|
| def keep_best_per_class(detections: sv.Detections, keypoints: sv.KeyPoints): |
| if len(detections) == 0: |
| return detections, keypoints |
| best_indices = {} |
| for i, (class_id, conf) in enumerate(zip(detections.class_id, detections.confidence)): |
| cid = int(class_id) |
| if cid not in best_indices or conf > detections.confidence[best_indices[cid]]: |
| best_indices[cid] = i |
| indices = sorted(best_indices.values()) |
| return detections[indices], sv.KeyPoints( |
| xy=keypoints.xy[indices], |
| confidence=keypoints.confidence[indices] if keypoints.confidence is not None else None, |
| class_id=keypoints.class_id[indices] if keypoints.class_id is not None else None, |
| ) |
|
|
|
|
| def annotate(image: np.ndarray, conf: float) -> np.ndarray: |
| h, w = image.shape[:2] |
| edge_map = build_edge_map(image) |
| HUD_H = 30 |
|
|
| |
| results = model(image, conf=conf, verbose=False)[0] |
| detections = sv.Detections.from_ultralytics(results) |
| keypoints = sv.KeyPoints.from_ultralytics(results) |
| detections, keypoints = keep_best_per_class(detections, keypoints) |
| detections.data["class_name"] = [CLASS_NAMES[int(c)] for c in detections.class_id] |
|
|
| |
| Y, X = np.mgrid[0:h, 0:w].astype(np.float32) |
| dist_v = np.sqrt(((X - w / 2) / w) ** 2 + ((Y - h / 2) / h) ** 2) |
| vignette = np.clip(1.0 - dist_v * 0.9, 0.50, 1.0)[..., np.newaxis] |
| annotated = (image.astype(np.float32) * 0.85 * vignette).clip(0, 255).astype(np.uint8) |
|
|
| |
| scan_mask = np.ones(h, dtype=np.float32) |
| scan_mask[1::4] = 0.88 |
| annotated = (annotated.astype(np.float32) * |
| scan_mask[:, np.newaxis, np.newaxis]).clip(0, 255).astype(np.uint8) |
|
|
| |
| line_data, traced_data = {}, {} |
| for i in range(len(detections)): |
| cn = CLASS_NAMES[int(detections.class_id[i])] |
| pts = keypoints.xy[i] |
| pts_list = [(int(x), int(y)) for x, y in pts] |
| line_data[cn] = pts_list |
| raw_traced = trace_line_on_edges( |
| edge_map, pts_list, steps_per_segment=40, search_radius=14) |
| traced_data[cn] = smooth_points(smooth_points(raw_traced, kernel=21), kernel=11) |
|
|
| placer = LabelPlacer(w, h, top_offset=HUD_H + 5, bottom_offset=HUD_H + 5) |
|
|
| |
| _lm_pts = None |
| _base_opts = _mp_python.BaseOptions(model_asset_path=_MODEL_PATH) |
| _lm_options = _mp_vision.HandLandmarkerOptions( |
| base_options=_base_opts, num_hands=1, min_hand_detection_confidence=0.4) |
|
|
| image_rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) |
| _mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=image_rgb) |
|
|
| with _mp_vision.HandLandmarker.create_from_options(_lm_options) as _detector: |
| _mp_res = _detector.detect(_mp_image) |
|
|
| hand_type, hand_desc, finger_lengths, mid_len = None, None, {}, 1 |
|
|
| if _mp_res.hand_landmarks: |
| _lm_pts = [(int(lm.x * w), int(lm.y * h)) |
| for lm in _mp_res.hand_landmarks[0]] |
|
|
| palm_w = np.hypot(_lm_pts[5][0] - _lm_pts[17][0], _lm_pts[5][1] - _lm_pts[17][1]) |
| palm_h = np.hypot(_lm_pts[0][0] - _lm_pts[9][0], _lm_pts[0][1] - _lm_pts[9][1]) |
| unit = palm_w * 0.22 |
|
|
| |
| _mid_5_9 = (np.array(_lm_pts[5]) + np.array(_lm_pts[9])) / 2 |
| _mid_9_13 = (np.array(_lm_pts[9]) + np.array(_lm_pts[13])) / 2 |
| _mid_13_17 = (np.array(_lm_pts[13]) + np.array(_lm_pts[17])) / 2 |
|
|
| mount_defs = [ |
| ("MỘC TINH", _mid_5_9 + np.array([0, unit * 0.8]), (unit * 1.1, unit * 0.9), 10), |
| ("THỔ TINH", np.array(_lm_pts[9]) + np.array([0, unit * 1.2]), (unit * 0.9, unit * 0.8), 0), |
| ("NHẬT TINH", _mid_9_13 + np.array([0, unit * 1.0]), (unit * 1.0, unit * 0.8), -5), |
| ("THỦY TINH", _mid_13_17 + np.array([0, unit * 0.9]), (unit * 1.0, unit * 0.8), -10), |
| ("KIM TINH", np.array(_lm_pts[1]) + np.array([unit * 0.3, unit * 0.6]), |
| (unit * 1.6, unit * 1.8), 20), |
| ("NGUYỆT", (np.array(_lm_pts[0]) + np.array(_lm_pts[17])) / 2 + np.array([unit * 0.5, -unit * 0.2]), |
| (unit * 1.3, unit * 1.5), -15), |
| ] |
| for mname, center, axes, angle in mount_defs: |
| draw_mount_ellipse(annotated, center, axes, angle, MOUNT_CFG[mname], alpha=0.08) |
|
|
| |
| for mname, center, axes, angle in mount_defs: |
| _put_text_centered(annotated, mname, |
| int(center[0]), int(center[1]), |
| _MOUNT_LABEL, size=_fs(0.28), bg_alpha=0.55) |
|
|
| |
| seg_fill = np.zeros_like(annotated, dtype=np.uint8) |
| for fname, (segs, widths, color) in FINGER_DEFS.items(): |
| for (a, b), hw in zip(segs, widths): |
| q = make_quad(_lm_pts, a, b, hw) |
| if q is not None: |
| cv2.fillPoly(seg_fill, [q], color) |
| seg_glow = cv2.GaussianBlur(seg_fill, (0, 0), sigmaX=18) |
| annotated = cv2.addWeighted(annotated, 1.0, seg_glow, 0.20, 0) |
|
|
| |
| seg_inner = np.zeros_like(annotated, dtype=np.uint8) |
| for fname, (segs, widths, color) in FINGER_DEFS.items(): |
| for (a, b), hw in zip(segs, widths): |
| q = make_quad(_lm_pts, a, b, hw * 0.55) |
| if q is not None: |
| cv2.fillPoly(seg_inner, [q], color) |
| seg_inner_b = cv2.GaussianBlur(seg_inner, (0, 0), sigmaX=6) |
| annotated = cv2.addWeighted(annotated, 1.0, seg_inner_b, 0.15, 0) |
|
|
| |
| for fname, (segs, widths, color) in FINGER_DEFS.items(): |
| for (a, b), hw in zip(segs, widths): |
| q = make_quad(_lm_pts, a, b, hw) |
| if q is not None: |
| cv2.polylines(annotated, [q], True, color, 1, cv2.LINE_AA) |
|
|
| |
| joint_layer = np.zeros_like(annotated, dtype=np.uint8) |
| for fname, (segs, widths, color) in FINGER_DEFS.items(): |
| for (a, b), _ in zip(segs, widths): |
| for idx in (a, b): |
| cv2.circle(joint_layer, _lm_pts[idx], 9, color, 1, cv2.LINE_AA) |
| joint_glow = cv2.GaussianBlur(joint_layer, (0, 0), sigmaX=4) |
| annotated = cv2.addWeighted(annotated, 1.0, joint_glow, 0.6, 0) |
| for fname, (segs, widths, color) in FINGER_DEFS.items(): |
| for (a, b), _ in zip(segs, widths): |
| for idx in (a, b): |
| cv2.circle(annotated, _lm_pts[idx], 3, color, -1, cv2.LINE_AA) |
| cv2.circle(annotated, _lm_pts[idx], 1, (40, 40, 40), -1, cv2.LINE_AA) |
|
|
| |
| for tip_idx, label in FINGERTIP_IDX.items(): |
| tx, ty = _lm_pts[tip_idx] |
| placer.place(annotated, label, tx, ty, _FINGER_LABEL, |
| font_scale=0.34, thickness=1, prefer_dir="above", leader=False) |
|
|
| |
| draw_angle_arc(annotated, _lm_pts[1], _lm_pts[2], _lm_pts[5], |
| _FINGER_DRAW, radius=28, thick=1) |
|
|
| |
| finger_lengths = { |
| "Cái": finger_length_px(_lm_pts, 1, 4), |
| "Trỏ": finger_length_px(_lm_pts, 5, 8), |
| "Giữa": finger_length_px(_lm_pts, 9, 12), |
| "Áp út": finger_length_px(_lm_pts, 13, 16), |
| "Út": finger_length_px(_lm_pts, 17, 20), |
| } |
| mid_len = max(finger_lengths["Giữa"], 1) |
|
|
| |
| hand_type, hand_desc = classify_hand_shape(_lm_pts) |
|
|
| |
| _line_band_mask = np.zeros((h, w), dtype=np.uint8) |
| _band_radius = 28 |
| for cn, traced in traced_data.items(): |
| if len(traced) > 1: |
| cv2.polylines(_line_band_mask, [np.array(traced, dtype=np.int32)], |
| False, 255, _band_radius * 2, cv2.LINE_AA) |
|
|
| _near_edges = cv2.bitwise_and(edge_map, edge_map, mask=_line_band_mask) |
| _near_edges_soft = cv2.GaussianBlur(_near_edges, (3, 3), sigmaX=1) |
|
|
| _edge_layer = np.zeros_like(annotated, dtype=np.uint8) |
| _edge_tint = (55, 55, 55) |
| for c in range(3): |
| _edge_layer[:, :, c] = (_near_edges_soft.astype(np.float32) / 255.0 * _edge_tint[c]).astype(np.uint8) |
| annotated = cv2.addWeighted(annotated, 1.0, _edge_layer, 0.5, 0) |
|
|
| |
| for cn, traced in traced_data.items(): |
| if len(traced) > 1: |
| color = LINE_CFG[cn]["color"] |
| pts_arr = np.array(traced, dtype=np.int32) |
| cv2.polylines(annotated, [pts_arr], False, (230, 235, 255), 2, cv2.LINE_AA) |
| cv2.polylines(annotated, [pts_arr], False, color, 1, cv2.LINE_AA) |
|
|
| |
| all_valid_pts = [(x, y) for pts in line_data.values() for x, y in pts if x > 0 and y > 0] |
| if all_valid_pts: |
| xs, ys = [p[0] for p in all_valid_pts], [p[1] for p in all_valid_pts] |
| pad_b = 36 |
| bx1, by1 = max(0, min(xs) - pad_b), max(HUD_H, min(ys) - pad_b) |
| bx2, by2 = min(w - 1, max(xs) + pad_b), min(h - 1 - HUD_H, max(ys) + pad_b) |
| hud_color = (80, 180, 255) |
| draw_bracket(annotated, bx1, by1, bx2, by2, hud_color, size=32, thick=2) |
| cr_x, cr_y = (bx1 + bx2) // 2, (by1 + by2) // 2 |
| cv2.line(annotated, (cr_x - 18, cr_y), (cr_x + 18, cr_y), hud_color, 1, cv2.LINE_AA) |
| cv2.line(annotated, (cr_x, cr_y - 18), (cr_x, cr_y + 18), hud_color, 1, cv2.LINE_AA) |
| cv2.circle(annotated, (cr_x, cr_y), 22, hud_color, 1, cv2.LINE_AA) |
| for tx in range(bx1 + 60, bx2 - 40, 30): |
| cv2.line(annotated, (tx, by1), (tx, by1 + 5), hud_color, 1, cv2.LINE_AA) |
| cv2.line(annotated, (tx, by2), (tx, by2 - 5), hud_color, 1, cv2.LINE_AA) |
|
|
| |
| cv2.rectangle(annotated, (0, 0), (w, HUD_H), (0, 0, 0), -1) |
| cv2.line(annotated, (0, HUD_H), (w, HUD_H), (80, 180, 255), 1, cv2.LINE_AA) |
| _put_text_pil(annotated, "HỆ THỐNG PHÂN TÍCH BÀN TAY // QUÉT CHỈ TAY", |
| 10, 20, (80, 180, 255), _fs(0.42)) |
| mp_ok = "OK" if _lm_pts else "N/A" |
| det_label = f"{len(detections)} ĐƯỜNG | MP:{mp_ok}" |
| _put_text_pil(annotated, det_label, |
| w - 10 - _pil_text_size(det_label, _fs(0.38))[0][0], 20, |
| (20, 255, 80), _fs(0.38)) |
| cv2.rectangle(annotated, (0, h - HUD_H), (w, h), (0, 0, 0), -1) |
| cv2.line(annotated, (0, h - HUD_H), (w, h - HUD_H), (80, 180, 255), 1, cv2.LINE_AA) |
|
|
| |
| for cn in ["love", "head", "life"]: |
| if cn not in line_data: |
| continue |
| valid = [(x, y) for x, y in line_data[cn] if x > 0 and y > 0] |
| if not valid: |
| continue |
| anchor_idx, prefer = LINE_ANCHOR[cn] |
| ax, ay = valid[anchor_idx] |
| traced = traced_data.get(cn, []) |
| curv = compute_line_curvature(traced) if len(traced) > 2 else 0 |
| length = compute_line_length_px(traced) if len(traced) > 1 else 0 |
| depth_label = "SÂU" if curv > 0.008 else ("VỪA" if curv > 0.003 else "NHẠT") |
| label_text = f"{LINE_CFG[cn]['label']} {length:.0f}px {depth_label}" |
| placer.place(annotated, label_text, ax, ay, LINE_CFG[cn]["color"], |
| font_scale=0.40, thickness=1, prefer_dir=prefer, leader=True) |
|
|
| |
| panel_lines = [] |
| if hand_type: |
| panel_lines.append(f"DẠNG: {hand_type} ({hand_desc})") |
| if finger_lengths: |
| ratios = " ".join(f"{k}:{v / mid_len:.2f}" for k, v in finger_lengths.items()) |
| panel_lines.append(f"TỶ LỆ: {ratios}") |
| if _lm_pts: |
| panel_lines.append(f"GÓC CÁI: {thumb_angle_deg(_lm_pts):.1f}\u00b0") |
|
|
| if panel_lines: |
| panel_font_sz = _fs(0.30) |
| line_h = 16 |
| panel_h = len(panel_lines) * line_h + 12 |
| panel_w_max = max(_pil_text_size(l, panel_font_sz)[0][0] for l in panel_lines) + 16 |
| px1 = w - panel_w_max - 8 |
| py1 = h - HUD_H - panel_h - 6 |
| overlay = annotated.copy() |
| cv2.rectangle(overlay, (px1, py1), (w - 6, py1 + panel_h), (0, 0, 0), -1) |
| annotated[:] = cv2.addWeighted(overlay, 0.75, annotated, 0.25, 0) |
| cv2.rectangle(annotated, (px1, py1), (w - 6, py1 + panel_h), |
| (80, 180, 255), 1, cv2.LINE_AA) |
| for i, pline in enumerate(panel_lines): |
| _put_text_pil(annotated, pline, px1 + 6, py1 + 13 + i * line_h, |
| (180, 200, 220), panel_font_sz) |
|
|
| return annotated |
|
|
|
|
| class AnnotateRequest(BaseModel): |
| image: str |
| confidence: float = 0.2 |
|
|
|
|
| class AnnotateResponse(BaseModel): |
| image: str |
|
|
|
|
| @app.post("/annotate", response_model=AnnotateResponse, summary="Nhận base64 image, trả về base64 image đã annotate") |
| def annotate_image(body: AnnotateRequest): |
| |
| b64_data = body.image |
| if "," in b64_data: |
| b64_data = b64_data.split(",", 1)[1] |
|
|
| |
| b64_data += "=" * (-len(b64_data) % 4) |
|
|
| try: |
| image_bytes = base64.b64decode(b64_data) |
| except Exception: |
| raise HTTPException(status_code=400, detail="base64 không hợp lệ") |
|
|
| nparr = np.frombuffer(image_bytes, np.uint8) |
| image = cv2.imdecode(nparr, cv2.IMREAD_COLOR) |
|
|
| if image is None: |
| raise HTTPException(status_code=400, detail="Không thể decode ảnh") |
|
|
| annotated = annotate(image, conf=body.confidence) |
|
|
| _, buffer = cv2.imencode(".jpg", annotated) |
| result_b64 = base64.b64encode(buffer).decode("utf-8") |
| return AnnotateResponse(image=result_b64) |
|
|
|
|
| @app.get("/health") |
| def health(): |
| return {"status": "ok"} |
|
|
|
|
| |
| def gradio_annotate(image: np.ndarray, confidence: float) -> np.ndarray: |
| if image is None: |
| return None |
| image_bgr = cv2.cvtColor(image, cv2.COLOR_RGB2BGR) |
| result = annotate(image_bgr, conf=confidence) |
| return cv2.cvtColor(result, cv2.COLOR_BGR2RGB) |
|
|
|
|
| with gr.Blocks(title="🖐 Menhso - Phân tích đường chỉ tay") as demo: |
| gr.Markdown("# 🖐 Menhso - Phân tích đường chỉ tay") |
| gr.Markdown("Upload ảnh bàn tay để phát hiện các đường chỉ tay (head, life, love) và landmarks.") |
| with gr.Row(): |
| with gr.Column(): |
| img_input = gr.Image(label="Ảnh bàn tay", type="numpy") |
| conf_slider = gr.Slider(0.1, 1.0, value=0.4, step=0.05, label="Confidence") |
| btn = gr.Button("Phân tích", variant="primary") |
| with gr.Column(): |
| img_output = gr.Image(label="Kết quả") |
| btn.click(fn=gradio_annotate, inputs=[img_input, conf_slider], outputs=img_output) |
|
|
| app = gr.mount_gradio_app(app, demo, path="/") |
|
|