save code

This commit is contained in:
xsl
2026-05-05 22:37:49 +08:00
parent bd1b7e66c8
commit 4e25641522
4 changed files with 458 additions and 1 deletions
+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)「已知限制」。
+13 -1
View File
@@ -13,7 +13,8 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from fastapi import FastAPI, File, Form, HTTPException, UploadFile from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.responses import JSONResponse, Response from fastapi.responses import FileResponse, JSONResponse, Response
from fastapi.staticfiles import StaticFiles
from .face_renderer import ( from .face_renderer import (
EffectNotFoundError, EffectNotFoundError,
@@ -32,6 +33,17 @@ _PATHS = RendererPaths(
app = FastAPI(title="Face SDK Web", version="0.1.0") app = FastAPI(title="Face SDK Web", version="0.1.0")
renderer = FaceRenderer(_PATHS) 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") @app.get("/health")
def health() -> dict: def health() -> dict:
+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>