2 Commits
Author SHA1 Message Date
xsl 4e25641522 save code 2026-05-05 22:37:49 +08:00
xsl bd1b7e66c8 save code 2026-05-05 21:44:42 +08:00
48 changed files with 3410 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"permissions": {
"allow": [
"Bash(awk '/^vt / {print $2, $3}' D:/face_sdk/app/src/main/assets/face_picture_3dmax.obj)",
"Bash(awk 'BEGIN{minu=99;maxu=-99;minv=99;maxv=-99} {if\\($1<minu\\)minu=$1; if\\($1>maxu\\)maxu=$1; if\\($2<minv\\)minv=$2; if\\($2>maxv\\)maxv=$2} END{print \"u:\",minu,maxu,\" v:\",minv,maxv}')",
"Bash(python -c \"from PIL import Image; print\\(Image.open\\('D:/face_sdk/example/src/main/assets/pic/4ex.png'\\).size\\)\")",
"Bash(python -c ' *)",
"Bash(python -c \"import sys; print\\(sys.executable\\)\")"
]
}
}
+2
View File
@@ -0,0 +1,2 @@
venv/
server/__pycache__/
+133
View File
@@ -0,0 +1,133 @@
# Face SDK Web 部署说明
本文说明如何在服务器或本机长期运行 **Face SDK Web**(FastAPI + 静态前端)。开发与接口文字说明见根目录 [README.md](./README.md)。
---
## API 文档是否已经写好?
有两层:
| 类型 | 说明 |
|------|------|
| **交互式文档(推荐联调)** | 服务启动后由 FastAPI **自动生成**:浏览器打开 **`/docs`**Swagger UI)、**`/redoc`**(ReDoc),可直接试请求。机器可读的模式在 **`/openapi.json`**。 |
| **文字说明** | [README.md](./README.md) 的「API」一节描述了 `GET /health``GET /effects``POST /render` 的请求与响应;与代码保持一致即可。 |
生产环境若不希望对外暴露 Swagger,应在网关层禁止访问 `/docs``/redoc``/openapi.json`(见下文反向代理示例)。
---
## 运行环境
- **Python**3.103.12MediaPipe 当前不支持 3.13)。
- **CPU**:无需 GPU;推理与渲染在 CPU 上完成。
- **系统**Windows / Linux / macOS 均可;生产常见为 Linux。
---
## 部署时需携带的文件
**`python/`** 目录下至少包含:
- `requirements.txt`
- `server/` 整个包,尤其:
- `server/main.py` 及依赖的 `.py`
- `server/assets/`(含 `face_picture_3dmax.obj``face_landmarker.task` 按 README 可选)
- `server/effects/``<id>.png` 效果贴图)
- `server/static/`(前端 `index.html` 及静态资源)
工作目录应为 **`python/`**(即包含 `server` 包的那一层),以便 `uvicorn server.main:app` 能正确导入。
---
## 安装依赖
```bash
cd /path/to/face_sdk/python
python3 -m venv venv
source venv/bin/activate # Windows: .\venv\Scripts\Activate.ps1
pip install --upgrade pip
pip install -r requirements.txt
```
---
## 启动方式
### 开发 / 小规模使用
```bash
cd /path/to/face_sdk/python
source venv/bin/activate
uvicorn server.main:app --host 0.0.0.0 --port 8000
```
- 根路径 **`/`**:返回 `server/static/index.html`
- **`/static/*`**:静态文件
- 日志出现 **`Application startup complete.`** 即就绪
### 生产建议(Linux
- 使用 **进程管理器**systemd、supervisor)或容器,保证崩溃自动拉起。
- 前面挂 **Nginx / Caddy / 云 LB**,统一 TLS 与限流。
- **Worker 数量**README「已知限制」中说明 MediaPipe 检测在应用内通过锁串行化;单进程多 worker **不会**线性提升同一实例内的 GPU 式并行,且可能重复加载模型占内存。更高并发可采用:**多实例(每实例单 worker)+ 负载均衡**,或前置队列削峰。
示例(单 worker,监听本机 127.0.0.1,由 Nginx 对外):
```bash
uvicorn server.main:app --host 127.0.0.1 --port 8000 --workers 1
```
---
## 反向代理示例(Nginx
将 HTTPS 流量转到本机 Uvicorn,并可选屏蔽文档路径:
```nginx
server {
listen 443 ssl;
server_name your.domain.example;
# ssl_certificate / ssl_certificate_key ...
location / {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 25m; # 略高于应用内图片上限(默认 20MB)
}
# 生产可注释掉以下整块,禁止外网访问 API 探索页
# location ~ ^/(docs|redoc|openapi\.json)$ {
# deny all;
# }
}
```
---
## 健康检查
负载均衡或编排(如 K8s)可使用:
- **`GET /health`** — 成功返回 `200`JSON`{"ok": true}`
---
## 防火墙与安全
- 若不经反向代理,直接监听 `0.0.0.0`,需在主机防火墙与安全组中 **仅开放必要端口**
- **`POST /render`** 接收用户上传文件,务必通过 HTTPS、合理 **`client_max_body_size`** / 限流,防止滥用。
---
## 常见问题
- **端口被占用**:更换 `--port` 或结束占用进程后再启动。
- **找不到模型或 OBJ**:确认 `server/assets/``server/effects/` 已随代码一并部署,且工作目录为 `python/`
更多行为与性能说明见 [README.md](./README.md)「已知限制」。
+146
View File
@@ -0,0 +1,146 @@
# Face SDK Web 服务
把 Android Face SDK 的人脸贴图渲染逻辑迁移到 Python Web 服务:上传一张人物图 + 效果 ID,
返回叠加渲染后的 PNG。CPU 实现(OpenCV 仿射 warp + alpha 混合),不依赖 GPU。
---
## 渲染原理(与 Android SDK 一致)
```
input image ──► MediaPipe FaceLandmarker ──► 478 normalized landmarks
face_picture_3dmax.obj (468 顶点+UV / 852 三角形)
每个 obj 顶点 i 的画面位置 = landmarks[INDEX_MAP[i]]
每个三角形:
src = obj UV × effect 贴图尺寸 (在 512×512 effect 里)
dst = landmarks 像素位置 (在 input image 里)
cv2.getAffineTransform → cv2.warpAffine → alpha 混合
output PNG(同原图尺寸)
```
`INDEX_MAP[468]` 同步自 `vulkan/hardcode_data.h::indexMap`UV 的 V 翻转与
`vulkan/FaceApp.cpp::LoadOBJ` 行为一致。
---
## 目录结构
```
python/
├── server/
│ ├── main.py FastAPI 入口(/health /effects /render
│ ├── face_renderer.py 渲染核心
│ ├── mesh.py OBJ 解析
│ ├── index_map.py OBJ vertex → MediaPipe landmark 映射
│ ├── assets/
│ │ ├── face_picture_3dmax.obj ← 来自 app/src/main/assets
│ │ └── face_landmarker.task ← 可选;不放则自动回退到 app/src/main/assets/
│ └── effects/
│ └── 1.png 512×512 RGBA 效果贴图,文件名是效果 ID(1-99)
└── requirements.txt
```
---
## 一次性安装
需要 Python 3.103.12mediapipe 当前不支持 3.13)。
```powershell
# Windows PowerShell(或 cmd
cd D:\face_sdk\python
python -m venv venv
.\venv\Scripts\Activate.ps1 # cmd 用 .\venv\Scripts\activate.bat
pip install --upgrade pip
pip install -r requirements.txt
```
```bash
# Linux / macOS
cd /path/to/face_sdk/python
python3 -m venv venv
source venv/bin/activate
pip install --upgrade pip
pip install -r requirements.txt
```
---
## 启动服务
```powershell
cd D:\face_sdk\python
.\venv\Scripts\Activate.ps1
uvicorn server.main:app --host 0.0.0.0 --port 8000
```
启动日志看到 `Application startup complete.` 即就绪。
---
## API
### `GET /health`
```json
{"ok": true}
```
### `GET /effects`
返回当前 `server/effects/` 下存在的效果 ID(按 `<id>.png` 文件名提取,199):
```json
{"effects": [1]}
```
### `POST /render`
- form-data
- `image`: 文件(jpeg / png / webp,≤ 20MB
- `effect_id`: 整数(199,必须在 `/effects` 列表内)
- 成功:`200``Content-Type: image/png`body 是 PNG 字节
- 没检测到人脸:`400 {"error":"no_face"}`
- effect_id 不存在:`404`
- 参数错误(图非法 / 太大 / id 越界):`400`
#### curl 示例
```bash
curl -X POST http://localhost:8000/render \
-F image=@/path/to/photo.jpg \
-F effect_id=1 \
--output result.png
```
#### Python 示例
```python
import requests
files = {"image": open("photo.jpg", "rb")}
data = {"effect_id": 1}
r = requests.post("http://localhost:8000/render", files=files, data=data)
r.raise_for_status()
open("result.png", "wb").write(r.content)
```
---
## 添加新效果
把一张 512×512 的 RGBA PNG 放到 `python/server/effects/<id>.png``<id>` 是 199 的整数)。
服务下次响应 `/effects` 时会自动列出;首次被请求时按需载入并缓存内存。
PNG 必须有 alpha 通道——透明区域不会盖到原图上。
---
## 已知限制
- 纯 2D 仿射 warp,与 Android SDK 行为一致:人脸侧面角度大时贴图会拉伸。
- MediaPipe FaceLandmarker 每次 `detect()` 不保证多线程并发安全,服务用锁串行化。
单进程同步部署够用;要更高并发请用多 worker 或前置队列。
- 单张 4K 图渲染约 100300ms852 三角形 × `cv2.warpAffine`);如需更快可改成
`cv2.remap` 一次性贴整脸,但需要更多代码。
+7
View File
@@ -0,0 +1,7 @@
fastapi>=0.110
uvicorn[standard]>=0.27
python-multipart>=0.0.9
mediapipe>=0.10.14
opencv-python>=4.9
pillow>=10.0
numpy>=1.26,<2.0
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

+312
View File
@@ -0,0 +1,312 @@
"""
人脸贴图渲染核心:接受一张图 + 一个效果 ID,输出 PNG 字节。
流程(与 Android 端 vulkan/FaceApp.cpp 一致,CPU 实现):
1. MediaPipe FaceLandmarker → 478 normalized landmarks per face
2. 取最大人脸(按 landmark bbox 面积)
3. 把 obj 的 468 顶点位置改写为 landmarks[INDEX_MAP[i]],得到 dst 顶点
4. 对 mesh 的每个三角形:从 512×512 effect 贴图取 src 三角形,仿射 warp
到 dst 三角形位置,按贴图 alpha 通道 alpha-blend 到原图副本上
5. 输出 PNG(保持原图尺寸)
"""
from __future__ import annotations
import io
import threading
from dataclasses import dataclass
from pathlib import Path
import cv2
import numpy as np
from PIL import Image, ImageOps
from .index_map import INDEX_MAP
from .mesh import FaceMesh, load_face_mesh
class NoFaceError(Exception):
"""图里检测不到人脸。"""
class EffectNotFoundError(Exception):
"""指定 effect_id 没有对应贴图。"""
@dataclass
class RendererPaths:
obj_path: Path
effects_dir: Path
class FaceRenderer:
"""
单例式渲染器:mesh / mediapipe 模型加载一次复用。
线程安全:MediaPipe FaceLandmarker 的 detect() 不保证多线程并发,
用 _lock 串行化。FastAPI 单进程同步部署下,请求本身就串行,开销可忽略。
"""
def __init__(self, paths: RendererPaths, max_image_bytes: int = 20 * 1024 * 1024):
self.paths = paths
self.max_image_bytes = max_image_bytes
self._mesh: FaceMesh = load_face_mesh(paths.obj_path)
if len(self._mesh.uvs) != len(INDEX_MAP):
raise RuntimeError(
f"mesh has {len(self._mesh.uvs)} vertices but INDEX_MAP has "
f"{len(INDEX_MAP)}; obj/index_map.py 不匹配"
)
# 预转 numpy 加速
self._index_map = np.asarray(INDEX_MAP, dtype=np.int32)
self._effect_cache: dict[int, np.ndarray] = {}
self._cache_lock = threading.Lock()
# MediaPipe 延迟加载(首次请求时加载,缩短启动时间,便于本地调试)
self._landmarker = None
self._landmarker_lock = threading.Lock()
# ---------- public ----------
def list_effect_ids(self) -> list[int]:
"""枚举 effects/<n>.png 中的 n。"""
ids: list[int] = []
if not self.paths.effects_dir.is_dir():
return ids
for p in self.paths.effects_dir.iterdir():
if p.suffix.lower() != ".png":
continue
try:
n = int(p.stem)
except ValueError:
continue
if 1 <= n <= 99:
ids.append(n)
return sorted(ids)
def render(self, image_bytes: bytes, effect_id: int) -> bytes:
if len(image_bytes) > self.max_image_bytes:
raise ValueError("image too large")
effect_rgba = self._load_effect(effect_id) # (H,W,4) uint8
# 1. 解码 + 处理 EXIF orientation → RGB ndarray
img_rgb = self._decode_input(image_bytes)
h, w = img_rgb.shape[:2]
# 2. MediaPipe 检测,挑最大人脸
landmarks_norm = self._detect_largest_face(img_rgb) # (N,2) ∈ [0,1]² (N>=468)
# 3. obj 顶点 → 像素坐标
dst_xy = self._compute_dst_vertices(landmarks_norm, w, h)
# 4. warp + 混合
out = img_rgb.copy()
self._composite(out, effect_rgba, dst_xy)
# 5. 编码 PNG
return self._encode_png(out)
# ---------- internals ----------
def _load_effect(self, effect_id: int) -> np.ndarray:
if not (1 <= effect_id <= 99):
raise EffectNotFoundError(f"effect_id out of range: {effect_id}")
with self._cache_lock:
cached = self._effect_cache.get(effect_id)
if cached is not None:
return cached
png_path = self.paths.effects_dir / f"{effect_id}.png"
if not png_path.is_file():
raise EffectNotFoundError(f"effect {effect_id} not found")
# 用 PIL 解码以保证 RGBA 一致;OpenCV imread 对部分 PNG 会丢 alpha
img = Image.open(png_path).convert("RGBA")
arr = np.array(img, dtype=np.uint8) # (H,W,4) RGBA
with self._cache_lock:
self._effect_cache[effect_id] = arr
return arr
def _decode_input(self, image_bytes: bytes) -> np.ndarray:
try:
pil = Image.open(io.BytesIO(image_bytes))
pil = ImageOps.exif_transpose(pil) # 处理手机 EXIF 旋转
pil = pil.convert("RGB")
except Exception as e:
raise ValueError(f"invalid image: {e}") from e
return np.array(pil, dtype=np.uint8)
def _ensure_landmarker(self):
if self._landmarker is not None:
return
with self._landmarker_lock:
if self._landmarker is not None:
return
# 延迟 import,避免单元测试时强依赖 mediapipe
from mediapipe.tasks import python as mp_python
from mediapipe.tasks.python import vision as mp_vision
model_path = self._find_model_file()
base_options = mp_python.BaseOptions(model_asset_path=str(model_path))
options = mp_vision.FaceLandmarkerOptions(
base_options=base_options,
running_mode=mp_vision.RunningMode.IMAGE,
num_faces=5, # 检测最多 5 张,挑最大那张
min_face_detection_confidence=0.5,
min_face_presence_confidence=0.5,
min_tracking_confidence=0.5,
output_face_blendshapes=False,
output_facial_transformation_matrixes=False,
)
self._landmarker = mp_vision.FaceLandmarker.create_from_options(options)
def _find_model_file(self) -> Path:
"""优先用本服务 assets 下的,找不到再回退到仓库 app/src/main/assets。"""
local = self.paths.obj_path.parent / "face_landmarker.task"
if local.is_file():
return local
# 回退到仓库 app/src/main/assets/face_landmarker.taskAndroid SDK 用同一份)
repo_root = self.paths.obj_path.resolve().parents[3]
fallback = repo_root / "app" / "src" / "main" / "assets" / "face_landmarker.task"
if fallback.is_file():
return fallback
raise FileNotFoundError(
"face_landmarker.task not found; place it under "
f"{local} or {fallback}"
)
def _detect_largest_face(self, img_rgb: np.ndarray) -> np.ndarray:
self._ensure_landmarker()
import mediapipe as mp
mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=img_rgb)
with self._landmarker_lock:
result = self._landmarker.detect(mp_image)
faces = result.face_landmarks or []
if not faces:
raise NoFaceError("no face detected")
# 选 bbox 面积最大的那张
def bbox_area(landmarks) -> float:
xs = [lm.x for lm in landmarks]
ys = [lm.y for lm in landmarks]
return (max(xs) - min(xs)) * (max(ys) - min(ys))
biggest = max(faces, key=bbox_area)
# 转 ndarray,只取 xy
n = len(biggest)
arr = np.empty((n, 2), dtype=np.float32)
for i, lm in enumerate(biggest):
arr[i, 0] = lm.x
arr[i, 1] = lm.y
return arr
def _compute_dst_vertices(
self, landmarks_norm: np.ndarray, w: int, h: int
) -> np.ndarray:
"""
landmarks_norm: (N, 2) ∈ [0,1]²
返回 (468, 2) float32 像素坐标,对应 mesh 的 468 个顶点。
"""
n_landmarks = landmarks_norm.shape[0]
if self._index_map.max() >= n_landmarks:
raise RuntimeError(
f"INDEX_MAP references landmark index {int(self._index_map.max())} "
f"but mediapipe returned only {n_landmarks} landmarks"
)
picked = landmarks_norm[self._index_map] # (468, 2)
out = np.empty_like(picked)
out[:, 0] = picked[:, 0] * w
out[:, 1] = picked[:, 1] * h
return out.astype(np.float32)
def _composite(
self,
canvas_rgb: np.ndarray,
effect_rgba: np.ndarray,
dst_xy: np.ndarray,
) -> None:
"""
在 canvas_rgbuint8 H×W×3,原地修改)上对 mesh 每个三角形:
从 effect_rgba 取对应 src 三角形 → warpAffine 到 dst → alpha 混合。
"""
eh, ew = effect_rgba.shape[:2]
canvas_h, canvas_w = canvas_rgb.shape[:2]
uvs = self._mesh.uvs # (468, 2)
tris = self._mesh.triangles # (852, 3)
# 把 UV 一次性转成 effect 像素坐标
src_pts_all = np.empty_like(uvs)
src_pts_all[:, 0] = uvs[:, 0] * ew
src_pts_all[:, 1] = uvs[:, 1] * eh
effect_rgb = effect_rgba[..., :3]
effect_a = effect_rgba[..., 3]
for tri in tris:
i0, i1, i2 = int(tri[0]), int(tri[1]), int(tri[2])
src_tri = np.float32([src_pts_all[i0], src_pts_all[i1], src_pts_all[i2]])
dst_tri = np.float32([dst_xy[i0], dst_xy[i1], dst_xy[i2]])
# 用 dst bbox 限定计算区域,避免对全画布 warp
x_min = int(np.floor(dst_tri[:, 0].min()))
y_min = int(np.floor(dst_tri[:, 1].min()))
x_max = int(np.ceil(dst_tri[:, 0].max()))
y_max = int(np.ceil(dst_tri[:, 1].max()))
# clip 到画布
x_min_c = max(0, x_min)
y_min_c = max(0, y_min)
x_max_c = min(canvas_w, x_max)
y_max_c = min(canvas_h, y_max)
if x_max_c <= x_min_c or y_max_c <= y_min_c:
continue
box_w = x_max_c - x_min_c
box_h = y_max_c - y_min_c
# 构造从 src → 局部 bbox 坐标系(左上为原点)的仿射
dst_tri_local = dst_tri - np.array([x_min_c, y_min_c], dtype=np.float32)
M = cv2.getAffineTransform(src_tri, dst_tri_local)
# 给 RGB 和 alpha 分别 warp 到 bbox 大小
warped_rgb = cv2.warpAffine(
effect_rgb,
M,
(box_w, box_h),
flags=cv2.INTER_LINEAR,
borderMode=cv2.BORDER_REPLICATE,
)
warped_a = cv2.warpAffine(
effect_a,
M,
(box_w, box_h),
flags=cv2.INTER_LINEAR,
borderMode=cv2.BORDER_CONSTANT,
borderValue=0,
)
# 限制只在三角形内部混合(防止 bbox 多余像素污染)
tri_mask = np.zeros((box_h, box_w), dtype=np.uint8)
cv2.fillConvexPoly(
tri_mask,
dst_tri_local.astype(np.int32),
255,
lineType=cv2.LINE_AA,
)
alpha = (
warped_a.astype(np.float32) * (tri_mask.astype(np.float32) / 255.0)
) / 255.0
alpha = alpha[..., None] # (h,w,1)
roi = canvas_rgb[y_min_c:y_max_c, x_min_c:x_max_c].astype(np.float32)
blended = roi * (1.0 - alpha) + warped_rgb.astype(np.float32) * alpha
canvas_rgb[y_min_c:y_max_c, x_min_c:x_max_c] = np.clip(
blended, 0, 255
).astype(np.uint8)
def _encode_png(self, img_rgb: np.ndarray) -> bytes:
pil = Image.fromarray(img_rgb)
buf = io.BytesIO()
pil.save(buf, format="PNG")
return buf.getvalue()
+61
View File
@@ -0,0 +1,61 @@
"""
OBJ 顶点索引 → MediaPipe FaceLandmarker landmark 索引的映射表。
直接从 vulkan/hardcode_data.h::indexMap[468] 同步而来。意义:
obj 顶点 i 在画面中的位置 = mediapipe_landmarks[INDEX_MAP[i]]
注意:face_picture_3dmax.obj 的顶点数量必须正好 468,与本表长度一致。
若以后换 obj,需要重新生成本表。
"""
INDEX_MAP = [
127, 34, 139, 11, 0, 37, 232, 231, 120, 72,
39, 128, 121, 47, 104, 69, 67, 175, 171, 148,
118, 50, 101, 73, 40, 9, 151, 108, 48, 115,
131, 194, 204, 211, 74, 185, 80, 42, 183, 92,
186, 230, 229, 202, 212, 214, 83, 18, 17, 76,
61, 146, 160, 29, 30, 56, 157, 173, 106, 135,
192, 203, 165, 98, 21, 71, 68, 51, 45, 4,
144, 24, 23, 77, 91, 205, 187, 201, 200, 182,
90, 181, 85, 84, 206, 36, 140, 193, 189, 244,
159, 158, 28, 247, 246, 161, 236, 3, 196, 54,
168, 8, 117, 228, 31, 55, 97, 99, 126, 100,
166, 79, 218, 155, 154, 26, 209, 49, 136, 150,
217, 223, 52, 53, 134, 170, 43, 119, 226, 130,
63, 238, 20, 242, 46, 70, 156, 78, 62, 96,
143, 227, 123, 111, 44, 125, 19, 216, 153, 22,
167, 208, 142, 57, 60, 35, 113, 27, 210, 225,
137, 116, 41, 38, 129, 64, 240, 102, 207, 184,
169, 149, 176, 105, 66, 122, 6, 147, 65, 107,
89, 180, 93, 15, 86, 14, 87, 145, 88, 179,
95, 138, 172, 215, 58, 219, 81, 195, 199, 82,
163, 110, 234, 109, 235, 191, 222, 141, 221, 197,
25, 7, 33, 220, 237, 245, 162, 188, 174, 2,
241, 164, 12, 13, 198, 133, 112, 243, 239, 190,
32, 178, 132, 177, 1, 213, 59, 94, 75, 224,
233, 114, 124, 356, 389, 368, 302, 267, 452, 350,
349, 303, 269, 357, 343, 277, 453, 333, 332, 297,
152, 377, 347, 348, 330, 304, 270, 336, 337, 278,
279, 360, 418, 262, 431, 408, 409, 310, 415, 407,
410, 450, 422, 430, 434, 313, 314, 306, 307, 375,
387, 388, 260, 286, 414, 398, 335, 406, 364, 367,
416, 423, 358, 327, 251, 284, 298, 281, 5, 373,
374, 253, 320, 321, 425, 427, 411, 421, 405, 404,
315, 16, 426, 266, 400, 369, 322, 391, 417, 465,
464, 386, 257, 258, 466, 456, 399, 419, 285, 346,
340, 261, 413, 441, 460, 328, 355, 371, 329, 392,
439, 438, 382, 341, 256, 429, 420, 394, 379, 437,
443, 444, 283, 275, 440, 363, 338, 273, 451, 446,
342, 467, 293, 334, 282, 458, 461, 462, 276, 353,
383, 308, 324, 325, 300, 372, 345, 447, 352, 274,
248, 436, 381, 252, 393, 428, 287, 250, 384, 265,
259, 424, 292, 366, 271, 294, 455, 272, 432, 395,
299, 351, 280, 319, 295, 296, 403, 323, 454, 316,
380, 318, 402, 365, 435, 397, 344, 311, 291, 396,
268, 445, 254, 339, 449, 264, 10, 442, 370, 263,
255, 359, 412, 301, 378, 326, 457, 362, 459, 463,
354, 401, 361, 309, 376, 433, 289, 305, 448, 290,
288, 249, 103, 385, 331, 317, 312, 390,
]
assert len(INDEX_MAP) == 468, f"INDEX_MAP must have 468 entries, got {len(INDEX_MAP)}"
+74
View File
@@ -0,0 +1,74 @@
"""
FastAPI 入口。启动:
python -m uvicorn server.main:app --host 0.0.0.0 --port 8000
或在 python/ 目录下:
uvicorn server.main:app --host 0.0.0.0 --port 8000
"""
from __future__ import annotations
from pathlib import Path
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.responses import FileResponse, JSONResponse, Response
from fastapi.staticfiles import StaticFiles
from .face_renderer import (
EffectNotFoundError,
FaceRenderer,
NoFaceError,
RendererPaths,
)
# 路径相对本文件,避免 cwd 不同时找不到资源
_SERVER_DIR = Path(__file__).resolve().parent
_PATHS = RendererPaths(
obj_path=_SERVER_DIR / "assets" / "face_picture_3dmax.obj",
effects_dir=_SERVER_DIR / "effects",
)
app = FastAPI(title="Face SDK Web", version="0.1.0")
renderer = FaceRenderer(_PATHS)
_STATIC_DIR = _SERVER_DIR / "static"
@app.get("/")
def index() -> FileResponse:
return FileResponse(_STATIC_DIR / "index.html")
# /static/* 提供其它资源(CSS/JS 拆分时用得上;当前 index.html 内联)
app.mount("/static", StaticFiles(directory=_STATIC_DIR), name="static")
@app.get("/health")
def health() -> dict:
return {"ok": True}
@app.get("/effects")
def list_effects() -> dict:
return {"effects": renderer.list_effect_ids()}
@app.post("/render")
async def render(
image: UploadFile = File(...),
effect_id: int = Form(...),
) -> Response:
image_bytes = await image.read()
if not image_bytes:
raise HTTPException(status_code=400, detail="empty image")
try:
png_bytes = renderer.render(image_bytes, effect_id)
except NoFaceError:
return JSONResponse(status_code=400, content={"error": "no_face"})
except EffectNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return Response(content=png_bytes, media_type="image/png")
+75
View File
@@ -0,0 +1,75 @@
"""
解析 face_picture_3dmax.obj,得到顶点 UV 和三角形索引。
OBJ 约定:v 顶点 / vt UV / vn 法线,f 三元组 v/vt/vn 索引(1-based)。
本服务只需要每个顶点的 UV 和三角形顶点索引——3D 顶点位置在 SDK 里也只是
占位(运行时被 MediaPipe landmark 覆盖),这里同样无需读 v。
★ 与 vulkan/FaceApp.cpp::LoadOBJ 一致,UV 的 V 分量取 (1 - v),把 OBJ 的
bottom-up 约定翻成 top-down,与图像(OpenCV/Pillow)一致。
"""
from dataclasses import dataclass
from pathlib import Path
import numpy as np
@dataclass
class FaceMesh:
# (468, 2) float32, 每个顶点的 UVV 已翻转,∈ [0,1]²
uvs: np.ndarray
# (852, 3) int32, 每个三角形的 3 个顶点索引(0-based,指向 uvs 第一维)
triangles: np.ndarray
def load_face_mesh(obj_path: str | Path) -> FaceMesh:
obj_path = Path(obj_path)
text = obj_path.read_text(encoding="utf-8", errors="ignore")
pos_count = 0
uv_list: list[tuple[float, float]] = []
# OBJ 的 face 用 v/vt/vn,三个索引可能不同;但本工程的 obj 三者一一对齐
# pos_index == uv_index == normal_index),所以我们只看 v 索引来索引 UV
# 列表也成立。这里仍然显式校验以防换 obj 时出错。
triangles: list[tuple[int, int, int]] = []
for raw in text.splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
parts = line.split()
tag = parts[0]
if tag == "v":
pos_count += 1
elif tag == "vt":
u = float(parts[1])
v = float(parts[2])
uv_list.append((u, 1.0 - v)) # V 翻转
elif tag == "f":
if len(parts) != 4:
raise ValueError(f"only triangle faces supported, got: {line}")
tri: list[int] = []
for token in parts[1:]:
# 形如 "v/vt/vn" 或 "v//vn" 或 "v"
seg = token.split("/")
v_idx = int(seg[0]) - 1 # OBJ 1-based → 0-based
vt_idx = int(seg[1]) - 1 if len(seg) > 1 and seg[1] else v_idx
if v_idx != vt_idx:
raise ValueError(
f"this loader assumes pos_idx == uv_idx, "
f"got v={v_idx + 1} vt={vt_idx + 1} in line: {line}"
)
tri.append(v_idx)
triangles.append((tri[0], tri[1], tri[2]))
if pos_count != len(uv_list):
raise ValueError(
f"vertex count {pos_count} != uv count {len(uv_list)}; "
"obj loader assumes 1:1 correspondence"
)
uvs = np.asarray(uv_list, dtype=np.float32)
tris = np.asarray(triangles, dtype=np.int32)
return FaceMesh(uvs=uvs, triangles=tris)
+310
View File
@@ -0,0 +1,310 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="theme-color" content="#0f1115" />
<title>Face SDK Web · 测试</title>
<style>
:root {
--bg: #0f1115;
--card: #1a1d24;
--border: #2a2f3a;
--text: #e6e8eb;
--muted: #8a92a3;
--accent: #4f8cff;
--accent-hover: #3d78e8;
--error: #ff6b6b;
--ok: #4fd18b;
}
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body { overflow-x: hidden; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
padding: 24px;
padding-left: max(24px, env(safe-area-inset-left));
padding-right: max(24px, env(safe-area-inset-right));
padding-bottom: max(24px, env(safe-area-inset-bottom));
min-height: 100vh;
min-height: 100dvh;
-webkit-text-size-adjust: 100%;
}
h1 { margin: 0 0 8px; font-size: 22px; font-weight: 600; }
.subtitle { color: var(--muted); margin-bottom: 24px; font-size: 13px; }
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 20px;
margin-bottom: 20px;
}
.row { display: flex; gap: 16px; flex-wrap: wrap; align-items: end; }
.field { display: flex; flex-direction: column; gap: 6px; flex: 1 1 200px; min-width: 0; }
label { font-size: 12px; color: var(--muted); }
/* font-size: 16px 防止 iOS Safari 聚焦时自动放大页面 */
input[type="file"], select {
background: #0f1115;
color: var(--text);
border: 1px solid var(--border);
border-radius: 6px;
padding: 10px 12px;
font-size: 16px;
width: 100%;
min-height: 44px;
-webkit-appearance: none;
appearance: none;
}
select {
/* 自定义下拉箭头(去掉默认样式后补回来) */
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'><path fill='%238a92a3' d='M6 8L0 0h12z'/></svg>");
background-repeat: no-repeat;
background-position: right 12px center;
padding-right: 32px;
}
button {
background: var(--accent);
color: white;
border: 0;
border-radius: 6px;
padding: 12px 20px;
font-size: 15px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s, transform 0.1s;
min-height: 44px;
touch-action: manipulation;
}
button:hover:not(:disabled) { background: var(--accent-hover); }
button:active:not(:disabled) { transform: scale(0.98); }
button:disabled { opacity: 0.5; cursor: not-allowed; }
.status { margin-top: 12px; font-size: 13px; min-height: 18px; word-break: break-word; }
.status.error { color: var(--error); }
.status.ok { color: var(--ok); }
.preview {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.preview-cell {
background: #0a0c10;
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px;
min-height: 200px;
display: flex;
flex-direction: column;
}
.preview-cell h3 {
margin: 0 0 10px;
font-size: 13px;
font-weight: 500;
color: var(--muted);
}
.preview-cell img {
max-width: 100%;
max-height: 70vh;
border-radius: 4px;
object-fit: contain;
align-self: center;
}
.placeholder {
color: var(--muted);
font-size: 13px;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 20px;
}
.download {
margin-top: 10px;
font-size: 13px;
}
.download a {
color: var(--accent);
text-decoration: none;
display: inline-block;
padding: 6px 0;
}
.download a:hover { text-decoration: underline; }
/* 平板 / 小桌面 */
@media (max-width: 720px) {
body { padding: 16px; padding-left: max(16px, env(safe-area-inset-left)); padding-right: max(16px, env(safe-area-inset-right)); }
h1 { font-size: 20px; }
.subtitle { margin-bottom: 16px; }
.card { padding: 16px; border-radius: 8px; margin-bottom: 16px; }
.row { gap: 12px; }
.field { flex: 1 1 100%; }
/* 移动端按钮整行,方便单手点 */
#submit-btn { width: 100%; flex: 1 1 100%; padding: 14px 20px; font-size: 16px; }
.preview { grid-template-columns: 1fr; gap: 12px; }
.preview-cell { padding: 10px; min-height: 160px; }
.preview-cell img { max-height: 60vh; max-height: 60dvh; }
}
/* 窄屏手机(375px 及以下) */
@media (max-width: 380px) {
body { padding: 12px; }
h1 { font-size: 18px; }
.card { padding: 14px; }
}
</style>
</head>
<body>
<h1>Face SDK Web · 测试页</h1>
<div class="subtitle">上传一张人脸图片,选择效果 ID,查看叠加渲染结果。</div>
<div class="card">
<div class="row">
<div class="field">
<label for="image-input">图片</label>
<input type="file" id="image-input" accept="image/*" />
</div>
<div class="field">
<label for="effect-select">效果 ID</label>
<select id="effect-select">
<option value="">加载中...</option>
</select>
</div>
<button id="submit-btn">渲染</button>
</div>
<div id="status" class="status"></div>
</div>
<div class="preview">
<div class="preview-cell">
<h3>原图</h3>
<div id="orig-wrap" class="placeholder">未选择图片</div>
</div>
<div class="preview-cell">
<h3>渲染结果</h3>
<div id="result-wrap" class="placeholder">等待渲染</div>
</div>
</div>
<script>
(() => {
const $ = (id) => document.getElementById(id);
const imageInput = $("image-input");
const effectSelect = $("effect-select");
const submitBtn = $("submit-btn");
const statusEl = $("status");
const origWrap = $("orig-wrap");
const resultWrap = $("result-wrap");
let lastResultUrl = null;
function setStatus(msg, kind) {
statusEl.textContent = msg || "";
statusEl.className = "status" + (kind ? " " + kind : "");
}
function showOriginal(file) {
origWrap.className = "";
origWrap.innerHTML = "";
const img = document.createElement("img");
img.src = URL.createObjectURL(file);
origWrap.appendChild(img);
}
function showResult(blob) {
if (lastResultUrl) URL.revokeObjectURL(lastResultUrl);
lastResultUrl = URL.createObjectURL(blob);
resultWrap.className = "";
resultWrap.innerHTML = "";
const img = document.createElement("img");
img.src = lastResultUrl;
resultWrap.appendChild(img);
const dl = document.createElement("div");
dl.className = "download";
const a = document.createElement("a");
a.href = lastResultUrl;
a.download = "result.png";
a.textContent = "下载 PNG";
dl.appendChild(a);
resultWrap.appendChild(dl);
}
async function loadEffects() {
try {
const res = await fetch("/effects");
if (!res.ok) throw new Error("HTTP " + res.status);
const data = await res.json();
const ids = data.effects || [];
effectSelect.innerHTML = "";
if (ids.length === 0) {
effectSelect.innerHTML = '<option value="">无可用效果</option>';
setStatus("server/effects/ 下没有 PNG 文件", "error");
return;
}
for (const id of ids) {
const opt = document.createElement("option");
opt.value = String(id);
opt.textContent = String(id);
effectSelect.appendChild(opt);
}
} catch (e) {
effectSelect.innerHTML = '<option value="">加载失败</option>';
setStatus("加载效果列表失败:" + e.message, "error");
}
}
imageInput.addEventListener("change", () => {
const f = imageInput.files && imageInput.files[0];
if (f) {
showOriginal(f);
setStatus("");
}
});
submitBtn.addEventListener("click", async () => {
const file = imageInput.files && imageInput.files[0];
const effectId = effectSelect.value;
if (!file) { setStatus("请先选择图片", "error"); return; }
if (!effectId) { setStatus("请选择效果 ID", "error"); return; }
submitBtn.disabled = true;
setStatus("渲染中...");
const t0 = performance.now();
try {
const fd = new FormData();
fd.append("image", file);
fd.append("effect_id", effectId);
const res = await fetch("/render", { method: "POST", body: fd });
if (!res.ok) {
let msg = "HTTP " + res.status;
try {
const j = await res.json();
if (j.error === "no_face") msg = "未检测到人脸";
else if (j.detail) msg = j.detail;
else if (j.error) msg = j.error;
} catch (_) { /* 非 JSON 响应 */ }
throw new Error(msg);
}
const blob = await res.blob();
showResult(blob);
const dt = ((performance.now() - t0) / 1000).toFixed(2);
setStatus(`完成(${dt}s`, "ok");
} catch (e) {
setStatus("失败:" + e.message, "error");
} finally {
submitBtn.disabled = false;
}
});
loadEffects();
})();
</script>
</body>
</html>