menhso / inference_server.py
chihq
Fix Gradio: use gr.Blocks and remove conflicting root GET route
803bd5b
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="/")