Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e25641522 | ||
|
|
bd1b7e66c8 |
@@ -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\\)\")"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
venv/
|
||||||
|
server/__pycache__/
|
||||||
@@ -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.10~3.12(MediaPipe 当前不支持 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)「已知限制」。
|
||||||
@@ -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.10–3.12(mediapipe 当前不支持 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` 文件名提取,1–99):
|
||||||
|
```json
|
||||||
|
{"effects": [1]}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `POST /render`
|
||||||
|
- form-data:
|
||||||
|
- `image`: 文件(jpeg / png / webp,≤ 20MB)
|
||||||
|
- `effect_id`: 整数(1–99,必须在 `/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>` 是 1–99 的整数)。
|
||||||
|
服务下次响应 `/effects` 时会自动列出;首次被请求时按需载入并缓存内存。
|
||||||
|
|
||||||
|
PNG 必须有 alpha 通道——透明区域不会盖到原图上。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 已知限制
|
||||||
|
|
||||||
|
- 纯 2D 仿射 warp,与 Android SDK 行为一致:人脸侧面角度大时贴图会拉伸。
|
||||||
|
- MediaPipe FaceLandmarker 每次 `detect()` 不保证多线程并发安全,服务用锁串行化。
|
||||||
|
单进程同步部署够用;要更高并发请用多 worker 或前置队列。
|
||||||
|
- 单张 4K 图渲染约 100–300ms(852 三角形 × `cv2.warpAffine`);如需更快可改成
|
||||||
|
`cv2.remap` 一次性贴整脸,但需要更多代码。
|
||||||
@@ -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
|
||||||
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
@@ -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.task(Android 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_rgb(uint8 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()
|
||||||
@@ -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)}"
|
||||||
@@ -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")
|
||||||
@@ -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, 每个顶点的 UV,V 已翻转,∈ [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)
|
||||||
@@ -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>
|
||||||