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") # ── Ensure hand landmarker model ─────────────────────────────────────────────── _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!") # ── PIL font helpers ─────────────────────────────────────────────────────────── _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)) # ── Edge detection helpers ───────────────────────────────────────────────────── 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)] # ── Drawing utilities ────────────────────────────────────────────────────────── 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) # ── Analysis helpers ─────────────────────────────────────────────────────────── 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) # ── Anti-overlap LabelPlacer ────────────────────────────────────────────────── 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) # ── Config ───────────────────────────────────────────────────────────────────── 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 # — YOLO inference — 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] # — 1. Bright base + soft vignette — 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) # — 2. Scan lines — 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) # — 3. Collect palm line data + smooth — 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) # — 4. MediaPipe hand landmarks — _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 # — 4a. Palm mount zones — _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) # — 4b. Mount labels — 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) # — 4c. Finger segments glow — 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) # — 4d. Inner bright fill — 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) # — 4e. Sharp outlines — 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) # — 4f. Joint dots — 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) # — 4g. Fingertip labels — 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) # — 4h. Thumb angle arc — draw_angle_arc(annotated, _lm_pts[1], _lm_pts[2], _lm_pts[5], _FINGER_DRAW, radius=28, thick=1) # — 4i. Finger length ratios — 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) # — 4j. Hand shape classification — hand_type, hand_desc = classify_hand_shape(_lm_pts) # — 4k. Edge overlay — cạnh gần 3 đường chính — _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) # — 5. Sharp palm lines — 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) # — 6. HUD corner brackets + reticle — 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) # — 8. HUD bars — 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) # — 9. Palm line labels + metrics — 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) # — 10. Data panel (bottom-right) — 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 # base64-encoded image (không có prefix data:image/...) confidence: float = 0.2 class AnnotateResponse(BaseModel): image: str # base64-encoded JPEG result @app.post("/annotate", response_model=AnnotateResponse, summary="Nhận base64 image, trả về base64 image đã annotate") def annotate_image(body: AnnotateRequest): # Xử lý data URI prefix nếu có (data:image/jpeg;base64,...) b64_data = body.image if "," in b64_data: b64_data = b64_data.split(",", 1)[1] # Thêm padding nếu thiếu 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"} # ── Gradio UI ────────────────────────────────────────────────────────────────── 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="/")