save code
This commit is contained in:
@@ -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)「已知限制」。
|
||||
+13
-1
@@ -13,7 +13,8 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
|
||||
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 (
|
||||
EffectNotFoundError,
|
||||
@@ -32,6 +33,17 @@ _PATHS = RendererPaths(
|
||||
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:
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user