新增 POST /api/v1/head/mask(worker + 网关代理)与测试页 test_interface9.html:
- MediaPipe 关键点连成额头分割线(21,68,104,69,108,151,337,299,333,298,251,
左端21/右端251 水平延伸到图片边缘),分割线以上为上半区。
- 头发分割 BiSeNet 与 SegFormer 两套并排对比;每列从最顶端头发向下填充到分割线,
得到含额头的闭合区域(不从发际线割断)。
- 外缘朝中心点151内缩 erode_cm(默认1.2cm,页面可调,虹膜标定换算像素)、底线不动。
- 复用现有 detector/hair_segmenter/SegFormer 单例(只读推理),无新依赖;纯新增,
不改动既有接口。
顺带修复接口2 遗留测试 test_grow_female_returns_5:hair_style 自 cb1989c 起必填,
补上 hair_style=1,2,3,4,5。全套 42 passed。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
603 lines
22 KiB
Python
603 lines
22 KiB
Python
"""外网网关 — FastAPI 应用。
|
||
|
||
薄反向代理层:对外保持 HTTPS 接口不变,对内转发到 worker 池。
|
||
不跑任何算法(无 torch/mediapipe/opencv 依赖)。
|
||
"""
|
||
|
||
import asyncio
|
||
import base64
|
||
import json
|
||
import logging
|
||
import time
|
||
from contextlib import asynccontextmanager
|
||
from io import BytesIO
|
||
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_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
|
||
|
||
# 确保标注图目录存在
|
||
static_dir = Path(cfg["static_dir"])
|
||
static_dir.mkdir(parents=True, exist_ok=True)
|
||
logger.info("标注图目录: %s", static_dir)
|
||
|
||
# 启动定期清理任务(阶段四)
|
||
cleanup_shutdown = asyncio.Event()
|
||
cleanup_task = asyncio.create_task(
|
||
_cleanup_loop(static_dir, cfg, cleanup_shutdown)
|
||
)
|
||
app.state._cleanup_shutdown = cleanup_shutdown
|
||
app.state._cleanup_task = cleanup_task
|
||
|
||
yield
|
||
|
||
# 关闭
|
||
logger.info("网关关闭中...")
|
||
# 停止清理任务
|
||
if app.state._cleanup_shutdown:
|
||
app.state._cleanup_shutdown.set()
|
||
if app.state._cleanup_task:
|
||
app.state._cleanup_task.cancel()
|
||
try:
|
||
await app.state._cleanup_task
|
||
except asyncio.CancelledError:
|
||
pass
|
||
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}
|
||
|
||
|
||
async def _cleanup_loop(annotations_dir: Path, cfg: dict, shutdown: asyncio.Event):
|
||
"""定期清理 static/annotations/ 中过期的标注图文件。
|
||
|
||
配置项(可选,在 config.json 中设定):
|
||
- cleanup.interval_minutes: 清理间隔,默认 60
|
||
- cleanup.max_age_hours: 文件保留时长(小时),默认 24
|
||
"""
|
||
cleanup_cfg = cfg.get("cleanup", {})
|
||
interval_s = cleanup_cfg.get("interval_minutes", 60) * 60
|
||
max_age_s = cleanup_cfg.get("max_age_hours", 24) * 3600
|
||
|
||
logger.info(
|
||
"清理任务启动 | 间隔=%dmin | 保留=%dh | 目录=%s",
|
||
interval_s // 60, max_age_s // 3600, annotations_dir,
|
||
)
|
||
|
||
while not shutdown.is_set():
|
||
try:
|
||
await asyncio.wait_for(shutdown.wait(), timeout=interval_s)
|
||
break # shutdown
|
||
except asyncio.TimeoutError:
|
||
pass # 正常到时,执行清理
|
||
|
||
now = time.time()
|
||
deleted = 0
|
||
for f in annotations_dir.iterdir():
|
||
if f.name == ".gitkeep":
|
||
continue
|
||
if not f.is_file():
|
||
continue
|
||
try:
|
||
age_s = now - f.stat().st_mtime
|
||
if age_s > max_age_s:
|
||
f.unlink()
|
||
deleted += 1
|
||
logger.debug("清理过期文件: %s (age=%.1fh)", f.name, age_s / 3600)
|
||
except Exception:
|
||
logger.warning("清理文件失败: %s", f.name, exc_info=True)
|
||
|
||
if deleted:
|
||
logger.info("清理完成: 删除 %d 个过期文件", deleted)
|
||
|
||
logger.info("清理任务已停止")
|
||
|
||
|
||
@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"],
|
||
}
|
||
|
||
|
||
@app.get("/health", include_in_schema=False)
|
||
async def health():
|
||
"""兼容旧 /health 路径,返回网关状态。"""
|
||
return await gateway_health()
|
||
|
||
|
||
@app.get("/", include_in_schema=False)
|
||
async def index():
|
||
return {
|
||
"service": "旷视五接口 — 网关",
|
||
"version": "0.1.0",
|
||
"docs": "/docs",
|
||
"stats": "/admin/stats",
|
||
"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",
|
||
},
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 请求统计仪表盘 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">实时请求监控 | 每 3 秒自动刷新</p>
|
||
<div class="nav">
|
||
<a href="/">← 返回首页</a> |
|
||
<a href="/docs">API 文档</a> |
|
||
<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()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 代理路由
|
||
# ---------------------------------------------------------------------------
|
||
# 所有接口统一走「选 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=["生发"])
|
||
async def hair_grow_b(request: Request):
|
||
"""接口3:B端生发"""
|
||
return await _proxy(request, "/api/v1/hair/grow-b")
|
||
|
||
|
||
@app.post("/api/v1/face/features", tags=["人脸分析"])
|
||
async def face_features(
|
||
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:用户特征分析 — 本机直接调豆包视觉模型,不经过 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,
|
||
})
|
||
|
||
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")
|