Files
hair/gateway/app.py
T
2026-10-15 23:04:07 +08:00

766 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""外网网关 — FastAPI 应用。
薄反向代理层:对外保持 HTTPS 接口不变,对内转发到 worker 池。
不跑任何算法(无 torch/mediapipe/opencv 依赖)。
"""
import asyncio
import base64
import json
import logging
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Optional
from fastapi import FastAPI, File, Form, Request, UploadFile
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from gateway.config import load_config
from gateway.logging_middleware import (
get_available_dates,
get_daily_stats,
get_stats,
init_logging as _init_req_logging,
request_logging_middleware,
)
# ---------------------------------------------------------------------------
# 日志
# ---------------------------------------------------------------------------
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger("gateway")
# ---------------------------------------------------------------------------
# 应用生命周期
# ---------------------------------------------------------------------------
@asynccontextmanager
async def lifespan(app: FastAPI):
"""启动:加载配置、初始化健康池;关闭:清理资源。"""
# 启动
cfg = load_config()
logger.info("网关启动中... workers=%s", cfg["workers"])
# 初始化请求日志
_init_req_logging(cfg)
# 初始化健康池(阶段二实现)
try:
from gateway.pool import init_pool, shutdown_pool as _pool_shutdown
_has_pool = True
except ImportError:
logger.warning("pool 模块未就绪,跳过健康池初始化")
_has_pool = False
if _has_pool:
await init_pool(cfg)
app.state._pool_shutdown = _pool_shutdown
else:
app.state._pool_shutdown = None
# 确保标注图目录存在(超过 30 天的文件由 crontab 每天 03:00 清理)
static_dir = Path(cfg["static_dir"])
static_dir.mkdir(parents=True, exist_ok=True)
logger.info("标注图目录: %s(>30 天文件由 crontab 每日清理)", static_dir)
yield
# 关闭
logger.info("网关关闭中...")
if app.state._pool_shutdown:
await app.state._pool_shutdown()
logger.info("网关已关闭")
# ---------------------------------------------------------------------------
# 创建应用
# ---------------------------------------------------------------------------
app = FastAPI(
title="旷视五接口 — 网关",
version="0.1.0",
description="外网网关:反向代理 5 个接口到高性能 worker 池。",
lifespan=lifespan,
)
# 静态文件托管(阶段四完善)
static_root = Path(__file__).resolve().parent.parent / "static"
static_root.mkdir(parents=True, exist_ok=True)
(static_root / "annotations").mkdir(parents=True, exist_ok=True)
app.mount("/static", StaticFiles(directory=str(static_root)), name="static")
# 请求日志中间件(在所有路由之前,静态文件之后)
app.middleware("http")(request_logging_middleware)
# ---------------------------------------------------------------------------
# 健康检查(网关自身)
# ---------------------------------------------------------------------------
def _get_pool_status_safe():
"""安全获取池状态(pool 未就绪时返回占位值)。"""
try:
from gateway.pool import get_pool_status
return get_pool_status()
except ImportError:
return {"total": 0, "healthy": 0, "busy": 0}
@app.get("/gateway-health", include_in_schema=False)
async def gateway_health():
"""网关自身健康检查(区别于 worker 的 /health)。"""
status = _get_pool_status_safe()
return {
"status": "ok",
"service": "gateway",
"workers_total": status["total"],
"workers_healthy": status["healthy"],
"workers_busy": status["busy"],
"workers_primary_total": status.get("primary_total", 0),
"workers_primary_healthy": status.get("primary_healthy", 0),
"global_max": status.get("global_max", 1),
"global_busy": status.get("global_busy", 0),
"global_waiting": status.get("global_waiting", 0),
}
@app.get("/health", include_in_schema=False)
async def health():
"""兼容旧 /health 路径,返回网关状态。"""
return await gateway_health()
# 监控演练开关。只影响 GET /monitor 的响应,不摘除 worker、不改变派发。
# 进程内存态,重启网关后自动关闭。
_monitor_drill = {"enabled": False}
def monitor_payload(status: dict, *, simulated: bool = False) -> dict:
"""根据池状态生成监控响应。至少一台 worker 在线才算服务正常。
simulated 为真时强制 unhealthy,用于监控报警演练;计数字段仍是真实池状态。
"""
healthy = int(status.get("healthy") or 0)
ok = healthy > 0 and not simulated
body = {
"status": "ok" if ok else "unhealthy",
"service": "gateway",
"workers_total": status.get("total", 0),
"workers_healthy": healthy,
"workers_primary_total": status.get("primary_total", 0),
"workers_primary_healthy": status.get("primary_healthy", 0),
}
if simulated:
body["simulated"] = True
if healthy > 0:
body["message"] = "模拟故障:监控测试中,真实 worker 未摘除"
else:
body["message"] = "模拟故障:监控测试中;同时当前没有可用的 worker"
elif not ok:
body["message"] = "没有可用的 worker"
return body
def _monitor_token_error(request: Request):
"""校验 X-Internal-Token 与网关 shared_password 一致。通过时返回 None。"""
from gateway.config import get_config
try:
expected = get_config().get("shared_password") or ""
except Exception: # noqa: BLE001
expected = ""
token = request.headers.get("X-Internal-Token")
if not expected or token != expected:
return JSONResponse(status_code=401, content={
"status": "unauthorized",
"message": "未授权:缺少或错误的 X-Internal-Token",
})
return None
@app.get("/monitor", include_in_schema=False)
async def monitor():
"""供外部监控探测当前服务是否可接业务。
有在线 worker 返回 200,全部下线返回 503(监控侧据此报警)。
演练开关打开时也返回 503,但 worker 池保持原样。
只读内存中的池状态,后台健康检查已按配置间隔探测过 worker,这里不再额外发包。
"""
body = monitor_payload(_get_pool_status_safe(), simulated=_monitor_drill["enabled"])
if body["status"] != "ok":
return JSONResponse(status_code=503, content=body)
return body
@app.post("/monitor/fault", include_in_schema=False)
async def monitor_fault(request: Request):
"""打开或关闭监控故障模拟。
Body 必须是 ``{"enabled": true}`` 或 ``{"enabled": false}``(JSON 布尔)。
打开后 GET /monitor 返回 503,业务派发不变。需要请求头 X-Internal-Token。
"""
denied = _monitor_token_error(request)
if denied is not None:
return denied
try:
payload = await request.json()
except Exception: # noqa: BLE001
payload = None
if not isinstance(payload, dict) or type(payload.get("enabled")) is not bool:
return JSONResponse(status_code=400, content={
"status": "error",
"message": '需要 JSON:{"enabled": true} 或 {"enabled": false}',
})
enabled = payload["enabled"]
_monitor_drill["enabled"] = enabled
if enabled:
logger.warning("监控故障模拟已开启:GET /monitor 将返回 503,worker 池未改动")
else:
logger.warning("监控故障模拟已关闭:GET /monitor 恢复真实池状态")
return {
"status": "ok",
"simulated": enabled,
"message": (
"已开启模拟故障,GET /monitor 将返回 503"
if enabled else
"已关闭模拟故障,GET /monitor 恢复真实状态"
),
}
@app.get("/", include_in_schema=False)
async def index():
return {
"service": "旷视五接口 — 网关",
"version": "0.1.0",
"docs": "/docs",
"stats": "/admin/stats",
"timing_dashboard": "/static/api_timing_dashboard.html",
"integration_guide": "/static/integration.html",
"test_pages": {
"if1_measure": "/static/test_interface1.html",
"if2_hair_grow": "/static/test_interface2.html",
"if3_hair_grow_b": "/static/test_interface3.html",
"if4_features": "/static/test_interface4.html",
"if5_hairline": "/static/test_interface5.html",
"if6_measure_v2": "/static/test_interface6.html",
"if7_hair_grow_v2": "/static/test_interface7.html",
"if9_head_mask": "/static/test_interface9.html",
"if10_head_band": "/static/test_interface10.html",
},
}
# ---------------------------------------------------------------------------
# 请求统计仪表盘 HTML
# ---------------------------------------------------------------------------
_STATS_PAGE_HTML = """<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>网关请求统计</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f5f5f5; color: #333; }
.container { max-width: 1300px; margin: 0 auto; padding: 24px; }
h1 { font-size: 22px; margin-bottom: 4px; }
.subtitle { color: #888; font-size: 13px; margin-bottom: 20px; }
.nav { margin-bottom: 20px; }
.nav a { color: #2563eb; text-decoration: none; font-size: 13px; }
.nav a:hover { text-decoration: underline; }
/* 汇总卡片 */
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 14px; margin-bottom: 24px; }
.stat-card { background: #fff; border-radius: 12px; padding: 18px 20px; box-shadow: 0 1px 4px rgba(0,0,0,.06); }
.stat-card .value { font-size: 28px; font-weight: 700; color: #111827; }
.stat-card .label { font-size: 11px; color: #9ca3af; text-transform: uppercase; letter-spacing: .5px; margin-top: 4px; }
.stat-card.ok .value { color: #059669; }
.stat-card.warn .value { color: #d97706; }
/* 表格 */
.section { margin-bottom: 24px; }
.section h2 { font-size: 16px; margin-bottom: 10px; color: #374151; }
.table-wrap { background: #fff; border-radius: 12px; overflow: hidden; box-shadow: 0 1px 4px rgba(0,0,0,.06); }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 9px 14px; text-align: left; border-bottom: 1px solid #f1f5f9; font-size: 13px; }
th { background: #f8fafc; font-weight: 700; color: #475569; font-size: 11px; text-transform: uppercase; letter-spacing: .3px; white-space: nowrap; }
tr:hover td { background: #fafbfc; }
td.mono { font-family: "SF Mono", "Fira Code", monospace; font-size: 12px; }
.badge { display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 11px; font-weight: 700; }
.badge-ok { background: #d1fae5; color: #065f46; }
.badge-err { background: #fee2e2; color: #991b1b; }
.badge-other { background: #f3f4f6; color: #6b7280; }
.duration-fast { color: #059669; }
.duration-mid { color: #d97706; }
.duration-slow { color: #dc2626; }
.footer { text-align: right; font-size: 12px; color: #9ca3af; margin-top: 20px; }
.auto-refresh { display: flex; align-items: center; gap: 8px; }
.auto-refresh input { accent-color: #2563eb; }
.empty { text-align: center; padding: 40px; color: #9ca3af; font-size: 14px; }
@media (max-width: 768px) {
.stats-grid { grid-template-columns: repeat(2, 1fr); }
th, td { padding: 6px 8px; font-size: 12px; }
}
</style>
</head>
<body>
<div class="container">
<h1>📊 网关请求统计</h1>
<p class="subtitle">实时请求监控 &nbsp;|&nbsp; 每 3 秒自动刷新</p>
<div class="nav">
<a href="/">← 返回首页</a> &nbsp;|&nbsp;
<a href="/docs">API 文档</a> &nbsp;|&nbsp;
<a href="/static/integration.html">接入指南</a>
</div>
<!-- 汇总卡片 -->
<div class="stats-grid">
<div class="stat-card"><div class="value" id="totalCount">—</div><div class="label">请求总数</div></div>
<div class="stat-card ok"><div class="value" id="successRate">—</div><div class="label">成功率(code=0)</div></div>
<div class="stat-card"><div class="value" id="avgTime">—</div><div class="label">平均响应时间</div></div>
<div class="stat-card"><div class="value" id="minTime">—</div><div class="label">最短响应</div></div>
<div class="stat-card warn"><div class="value" id="maxTime">—</div><div class="label">最长响应</div></div>
</div>
<!-- 按接口 -->
<div class="section">
<h2>📋 按接口统计</h2>
<div class="table-wrap">
<table>
<thead><tr><th>路径</th><th>请求数</th><th>平均耗时</th><th>最大耗时</th><th>成功率</th></tr></thead>
<tbody id="endpointTable"><tr><td class="empty" colspan="5">暂无数据</td></tr></tbody>
</table>
</div>
</div>
<!-- 按 Worker -->
<div class="section">
<h2>🖥️ 按 GPU Worker 统计</h2>
<div class="table-wrap">
<table>
<thead><tr><th>Worker</th><th>请求数</th><th>平均耗时</th><th>成功率</th></tr></thead>
<tbody id="workerTable"><tr><td class="empty" colspan="4">暂无数据</td></tr></tbody>
</table>
</div>
</div>
<!-- 最近请求 -->
<div class="section">
<h2>🕐 最近请求(最新 100 条)</h2>
<div class="table-wrap" style="max-height:600px;overflow:auto;">
<table>
<thead><tr><th>时间</th><th>方法</th><th>路径</th><th>Worker</th><th>HTTP</th><th>业务码</th><th>耗时</th><th>客户端 IP</th></tr></thead>
<tbody id="recentTable"><tr><td class="empty" colspan="8">暂无数据</td></tr></tbody>
</table>
</div>
</div>
<div class="footer">
<label class="auto-refresh">
<input type="checkbox" id="autoRefresh" checked onchange="toggleAutoRefresh()"> 自动刷新(3s)
</label>
<span style="margin-left:16px" id="lastUpdated">加载中…</span>
</div>
</div>
<script>
let _timer = null;
function formatDuration(ms) {
if (ms < 1000) return ms.toFixed(1) + 'ms';
if (ms < 60000) return (ms / 1000).toFixed(2) + 's';
return (ms / 60000).toFixed(1) + 'min';
}
function durationClass(ms) {
if (ms < 500) return 'duration-fast';
if (ms < 2000) return 'duration-mid';
return 'duration-slow';
}
function badgeClass(code) {
if (code === 0) return 'badge-ok';
if (code !== null && code !== undefined && code !== 0) return 'badge-err';
return 'badge-other';
}
function badgeText(code) {
if (code === 0) return 'OK';
if (code !== null && code !== undefined) return 'ERR ' + code;
return '—';
}
async function refresh() {
try {
const r = await fetch('/admin/stats/json');
const data = await r.json();
const s = data.summary;
document.getElementById('totalCount').textContent = s.total.toLocaleString();
document.getElementById('successRate').textContent = s.success_rate + '%';
document.getElementById('avgTime').textContent = formatDuration(s.avg_duration_ms);
document.getElementById('minTime').textContent = formatDuration(s.min_duration_ms);
document.getElementById('maxTime').textContent = formatDuration(s.max_duration_ms);
// 按接口
let ehtml = '';
if (data.endpoints.length === 0) {
ehtml = '<tr><td class="empty" colspan="5">暂无数据</td></tr>';
} else {
data.endpoints.forEach(function(e) {
ehtml += '<tr>' +
'<td class="mono">' + e.path + '</td>' +
'<td>' + e.count + '</td>' +
'<td class="' + durationClass(e.avg_duration_ms) + '">' + formatDuration(e.avg_duration_ms) + '</td>' +
'<td>' + formatDuration(e.max_duration_ms) + '</td>' +
'<td><span class="badge ' + badgeClass(0) + '" style="opacity:' + (e.success_rate / 100) + '">' + e.success_rate + '%</span></td>' +
'</tr>';
});
}
document.getElementById('endpointTable').innerHTML = ehtml;
// 按 Worker
let whtml = '';
if (!data.workers || data.workers.length === 0) {
whtml = '<tr><td class="empty" colspan="4">暂无数据</td></tr>';
} else {
data.workers.forEach(function(w) {
whtml += '<tr>' +
'<td class="mono">' + w.worker + '</td>' +
'<td>' + w.count + '</td>' +
'<td class="' + durationClass(w.avg_duration_ms) + '">' + formatDuration(w.avg_duration_ms) + '</td>' +
'<td><span class="badge ' + badgeClass(0) + '" style="opacity:' + (w.success_rate / 100) + '">' + w.success_rate + '%</span></td>' +
'</tr>';
});
}
document.getElementById('workerTable').innerHTML = whtml;
// 最近请求
let rhtml = '';
if (data.recent.length === 0) {
rhtml = '<tr><td class="empty" colspan="8">暂无数据</td></tr>';
} else {
data.recent.forEach(function(entry) {
var ts = entry.timestamp.replace('T', ' ').substring(0, 23);
var workerDisplay = entry.worker || '—';
// 短 worker 显示:只取主机部分
if (workerDisplay.length > 30) {
workerDisplay = workerDisplay.replace(/^https?:\/\//, '').substring(0, 28) + '…';
}
rhtml += '<tr>' +
'<td class="mono">' + ts + '</td>' +
'<td>' + entry.method + '</td>' +
'<td class="mono">' + entry.path + '</td>' +
'<td class="mono" style="font-size:11px">' + workerDisplay + '</td>' +
'<td>' + entry.status_code + '</td>' +
'<td><span class="badge ' + badgeClass(entry.response_code) + '">' + badgeText(entry.response_code) + '</span></td>' +
'<td class="' + durationClass(entry.duration_ms) + '">' + formatDuration(entry.duration_ms) + '</td>' +
'<td class="mono">' + entry.client_ip + '</td>' +
'</tr>';
});
}
document.getElementById('recentTable').innerHTML = rhtml;
document.getElementById('lastUpdated').textContent = '最后更新: ' + new Date().toLocaleTimeString();
} catch(err) {
document.getElementById('lastUpdated').textContent = '加载失败: ' + err.message;
}
}
function toggleAutoRefresh() {
var checked = document.getElementById('autoRefresh').checked;
if (checked) {
_timer = setInterval(refresh, 3000);
} else {
clearInterval(_timer);
_timer = null;
}
}
refresh();
_timer = setInterval(refresh, 3000);
</script>
</body>
</html>"""
# ---------------------------------------------------------------------------
# 请求统计页面
# ---------------------------------------------------------------------------
@app.get("/admin/stats", include_in_schema=False)
async def admin_stats():
"""请求统计仪表盘(HTML 页面)。"""
return HTMLResponse(content=_STATS_PAGE_HTML)
@app.get("/admin/stats/json", include_in_schema=False)
async def admin_stats_json():
"""请求统计数据(JSON,供页面轮询)。"""
return get_stats()
@app.get("/admin/stats/dates", include_in_schema=False)
async def admin_stats_dates():
"""返回日志中出现过的所有本地日期(供耗时看板日期选择器使用)。"""
return {"dates": get_available_dates()}
@app.get("/admin/stats/daily", include_in_schema=False)
async def admin_stats_daily(date: str):
"""按天统计各接口调用耗时(含多发型接口的单次换发型耗时拆解)。"""
return get_daily_stats(date)
# ---------------------------------------------------------------------------
# 代理路由
# ---------------------------------------------------------------------------
# 所有接口统一走「选 worker → 转发 → 改写 base64 → 返回」链路。
# 使用 Request 对象直接读取并转发,不做业务入参解析(解析在 worker 侧完成)。
# Form/File 声明保留在 OpenAPI extra 中以便文档生成。
def _proxy(request: Request, path: str):
"""延迟导入 proxy_request。"""
from gateway.forward import proxy_request
return proxy_request(request, path)
# 声明各接口的 form 参数用于 OpenAPI schema(实际转发直接读 Request)
_MEASURE_FORMS = {
"image_file": {"type": "file", "description": "上传图片文件(JPG/PNG)"},
"image_url": {"type": "string", "description": "图片 URL"},
"image_base64": {"type": "string", "description": "图片 base64(需带前缀)"},
}
_GROW_FORMS = {
**_MEASURE_FORMS,
"beauty_enabled": {"type": "boolean", "description": "是否开启美颜效果"},
}
_GROW_B_FORMS = {
"marked_image_file": {"type": "file", "description": "划线图片文件"},
"marked_image_url": {"type": "string", "description": "划线图片 URL"},
"marked_image_base64": {"type": "string", "description": "划线图片 base64"},
}
@app.post("/api/v1/face/measure", tags=["人脸分析"])
async def face_measure(request: Request):
"""接口1:四庭七眼测量标注"""
return await _proxy(request, "/api/v1/face/measure")
@app.post("/api/v1/face/measure-v2", tags=["人脸分析"])
async def face_measure_v2(request: Request):
"""接口6:四庭七眼测量标注 v2(去顶庭 + 去头部端线)"""
return await _proxy(request, "/api/v1/face/measure-v2")
@app.post("/api/v1/hair/grow", tags=["生发"])
async def hair_grow(request: Request):
"""接口2:C端生发"""
return await _proxy(request, "/api/v1/hair/grow")
@app.post("/api/v1/hair/grow-b", tags=["生发"], deprecated=True)
async def hair_grow_b(request: Request):
"""接口3:B端生发(已屏蔽)"""
# 在网关层直接拦截,不转发到 worker 池——对所有上游 worker 立即生效。
import uuid as _uuid
return JSONResponse(status_code=200, content={
"code": 1007, "message": "接口3(/api/v1/hair/grow-b)已屏蔽,暂不提供服务",
"request_id": f"gw-{_uuid.uuid4().hex[:8]}", "data": None,
})
@app.post("/api/v1/face/features", tags=["人脸分析"])
async def face_features(
request: Request,
image_file: Optional[UploadFile] = File(default=None, description="上传图片文件(JPG/PNG)"),
image_url: Optional[str] = Form(default=None, description="图片 URL"),
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带前缀)"),
):
"""接口4:用户特征分析 — 豆包 6 项 + 本机 MediaPipe 脸型(face/),不经过 worker。"""
import uuid as _uuid
# 三选一校验
provided = [x for x in (image_file, image_url, image_base64) if x]
if len(provided) != 1:
return JSONResponse(status_code=200, content={
"code": 1007, "message": "图片参数错误:必须且只能传 image_file / image_url / image_base64 其中一个",
"request_id": f"gw-{_uuid.uuid4().hex[:8]}", "data": None,
})
img_bytes = None
if image_file:
img_bytes = await image_file.read()
elif image_base64:
b64 = image_base64
if "," in b64:
b64 = b64.split(",", 1)[1]
try:
img_bytes = base64.b64decode(b64)
except Exception:
return JSONResponse(status_code=200, content={
"code": 1008, "message": "图片格式不支持(base64 解码失败)",
"request_id": f"gw-{_uuid.uuid4().hex[:8]}", "data": None,
})
# 入参日志(接口4 不走 forward.py,自行记录;不获取全局串行槽,因不占 GPU)
try:
from gateway.config import get_config as _get_cfg
from gateway.reqlog import save_image_bytes
_cfg = _get_cfg()
_params: dict = {}
if image_file:
_url = save_image_bytes(img_bytes, _cfg["static_dir"], _cfg["public_base_url"]) if img_bytes else None
_params["image_file"] = {"_image": True, "source": "file",
"size": len(img_bytes) if img_bytes else 0, "url": _url}
elif image_base64:
_url = save_image_bytes(img_bytes, _cfg["static_dir"], _cfg["public_base_url"]) if img_bytes else None
_params["image_base64"] = {"_image": True, "source": "base64",
"size": len(img_bytes) if img_bytes else 0, "url": _url}
elif image_url:
_params["image_url"] = image_url
request.state.log_request_params = _params
except Exception: # noqa: BLE001
request.state.log_request_params = {"_parse_error": "iface4"}
from fastapi.concurrency import run_in_threadpool
from face_features import analyze_features
try:
feats = await run_in_threadpool(analyze_features, img_bytes, image_url)
except Exception as ex:
logger.exception("接口4 豆包调用失败")
return JSONResponse(status_code=200, content={
"code": 1007, "message": f"分析服务异常:{ex}",
"request_id": f"gw-{_uuid.uuid4().hex[:8]}", "data": None,
})
if feats is None:
return JSONResponse(status_code=200, content={
"code": 1001, "message": "无法识别人像",
"request_id": f"gw-{_uuid.uuid4().hex[:8]}", "data": None,
})
return JSONResponse(status_code=200, content={
"code": 0,
"message": "success",
"request_id": f"gw-{_uuid.uuid4().hex[:8]}",
"data": {"features": json.dumps(feats, ensure_ascii=False)},
})
@app.post("/api/v1/hairline/generate", tags=["人脸分析"])
async def hairline_generate(request: Request):
"""接口5:发际线PNG生成"""
return await _proxy(request, "/api/v1/hairline/generate")
@app.post("/api/v1/hair/grow-v2", tags=["生发"])
async def hair_grow_v2(request: Request):
"""接口7:C端生发 v2(add_hair2 工作流)"""
return await _proxy(request, "/api/v1/hair/grow-v2")
@app.post("/api/v1/head/mask", tags=["人脸分析"])
async def head_mask(request: Request):
"""接口9:头发遮罩生成 + 分步可视化"""
return await _proxy(request, "/api/v1/head/mask")
@app.post("/api/v1/head/band", tags=["人脸分析"])
async def head_band(request: Request):
"""接口10:头部外缘膨胀带遮罩 + 分步可视化"""
return await _proxy(request, "/api/v1/head/band")
# 接口99 单路排队:网关机推理全局互斥。调用频率低,无需并发——串行既限制 CPU 峰值
# (MediaPipe + BiSeNet 全 CPU 推理),也规避检测器单例的线程安全风险。排队无超时,
# 请求方按需自带客户端超时。
_if99_lock = asyncio.Lock()
@app.post("/api/v1/face/special-points", tags=["人脸分析"])
async def face_special_points(
image_file: Optional[UploadFile] = File(default=None, description="上传图片文件(JPG/PNG)"),
image_url: Optional[str] = Form(default=None, description="图片 URL"),
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带前缀)"),
):
"""接口99:人脸特殊点检测 — 网关本机实现(纯 CPU:MediaPipe + BiSeNet CPU),不经过 worker。
与 worker 同仓库,直接 import face_analysis.special_points;权重已入 git,clone 即用。
依赖惰性导入:网关缺依赖时仅本接口报 1007,不影响其他路由。
"""
import uuid as _uuid
rid = f"gw-{_uuid.uuid4().hex[:8]}"
def _err(code, message):
return JSONResponse(status_code=200, content={
"code": code, "message": message, "request_id": rid, "data": None})
# 三选一校验
provided = [x for x in (image_file, image_url, image_base64) if x]
if len(provided) != 1:
return _err(1007, "图片参数错误:必须且只能传 image_file / image_url / image_base64 其中一个")
img_bytes = None
if image_file:
img_bytes = await image_file.read()
elif image_base64:
b64 = image_base64.split(",", 1)[1] if "," in image_base64 else image_base64
try:
img_bytes = base64.b64decode(b64)
except Exception:
return _err(1008, "图片格式不支持(base64 解码失败)")
else:
try:
import httpx
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as cli:
r = await cli.get(image_url)
r.raise_for_status()
img_bytes = r.content
except Exception:
return _err(1008, "图片下载失败(URL 不可达)")
try:
import cv2
import numpy as np # noqa: F401 (detect_special_points 依赖)
from face_analysis.special_points import (
detect_special_points, NoFaceError, NonFrontalError)
except ImportError as ex:
return _err(1007, f"网关依赖未安装(安装清单见 docs/网关待改动.md §9):{ex}")
image = cv2.imdecode(np.frombuffer(img_bytes, np.uint8), cv2.IMREAD_COLOR)
if image is None:
return _err(1008, "图片格式不支持(仅 JPG / PNG)")
from fastapi.concurrency import run_in_threadpool
try:
async with _if99_lock: # 单路排队:同一时刻只跑一路推理
data = await run_in_threadpool(detect_special_points, image)
except NoFaceError:
return _err(1001, "无法识别人像")
except NonFrontalError:
return _err(1003, "角度问题,请上传正面照")
except Exception as ex: # noqa: BLE001
logger.exception("接口99 处理异常")
return _err(1007, f"处理失败:{ex}")
return JSONResponse(status_code=200, content={
"code": 0, "message": "success", "request_id": rid, "data": data})