Merge branch 'main' of http://git.xiangsilian.com:3000/xsl/hair
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>API 耗时看板</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: 1200px; margin: 0 auto; padding: 24px; }
|
||||
h1 { font-size: 22px; margin-bottom: 4px; }
|
||||
.subtitle { color: #888; font-size: 13px; margin-bottom: 18px; }
|
||||
.nav { margin-bottom: 18px; }
|
||||
.nav a { color: #2563eb; text-decoration: none; font-size: 13px; margin-right: 14px; }
|
||||
.nav a:hover { text-decoration: underline; }
|
||||
|
||||
.toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; flex-wrap: wrap; }
|
||||
.toolbar label { font-size: 13px; font-weight: 600; color: #374151; }
|
||||
.toolbar select, .toolbar button { padding: 8px 14px; border: 1px solid #d1d5db; border-radius: 8px; font-size: 14px; background: #fff; cursor: pointer; }
|
||||
.toolbar select { min-width: 160px; }
|
||||
.toolbar button { background: #2563eb; color: #fff; border: none; font-weight: 600; }
|
||||
.toolbar button:hover { background: #1d4ed8; }
|
||||
#loadingHint { font-size: 12px; color: #9ca3af; }
|
||||
|
||||
.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: 26px; 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; }
|
||||
|
||||
.hourly-wrap { background: #fff; border-radius: 12px; padding: 16px 20px; box-shadow: 0 1px 4px rgba(0,0,0,.06); margin-bottom: 24px; }
|
||||
.hourly-wrap h2 { font-size: 14px; color: #374151; margin-bottom: 12px; }
|
||||
.hourly-bars { display: flex; align-items: flex-end; gap: 4px; height: 80px; }
|
||||
.hourly-bar { flex: 1; background: #93c5fd; border-radius: 3px 3px 0 0; position: relative; min-height: 2px; }
|
||||
.hourly-bar .cnt { position: absolute; top: -16px; left: 0; right: 0; text-align: center; font-size: 10px; color: #6b7280; }
|
||||
.hourly-labels { display: flex; gap: 4px; margin-top: 4px; }
|
||||
.hourly-labels span { flex: 1; text-align: center; font-size: 10px; color: #9ca3af; }
|
||||
|
||||
.ep-card { background: #fff; border-radius: 12px; box-shadow: 0 1px 4px rgba(0,0,0,.06); margin-bottom: 16px; overflow: hidden; }
|
||||
.ep-header { padding: 14px 18px; background: #fafafa; border-bottom: 1px solid #f0f0f0; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.ep-badge { background: #2563eb; color: #fff; padding: 3px 12px; border-radius: 12px; font-size: 12px; font-weight: 700; white-space: nowrap; }
|
||||
.ep-name { font-size: 15px; font-weight: 700; color: #111827; }
|
||||
.ep-path { font-family: "SF Mono", "Fira Code", monospace; font-size: 12px; color: #9ca3af; }
|
||||
.ep-success { margin-left: auto; font-size: 12px; }
|
||||
.ep-body { padding: 16px 18px; }
|
||||
|
||||
.metrics-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); gap: 10px; margin-bottom: 4px; }
|
||||
.metric { text-align: center; padding: 10px 4px; background: #f9fafb; border-radius: 8px; }
|
||||
.metric .v { font-size: 17px; font-weight: 700; color: #1f2937; }
|
||||
.metric .l { font-size: 10px; color: #9ca3af; margin-top: 2px; text-transform: uppercase; letter-spacing: .3px; }
|
||||
|
||||
.style-box { margin-top: 14px; background: #eff6ff; border: 1px solid #bfdbfe; border-radius: 10px; padding: 14px 16px; }
|
||||
.style-box .title { font-size: 13px; font-weight: 700; color: #1e40af; margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
|
||||
.style-box .formula { font-size: 12px; color: #1e3a8a; margin-bottom: 10px; font-family: "SF Mono", "Fira Code", monospace; background: #dbeafe; padding: 6px 10px; border-radius: 6px; display: inline-block; }
|
||||
.style-metrics { display: flex; gap: 24px; margin-bottom: 10px; flex-wrap: wrap; }
|
||||
.style-metrics .sm { }
|
||||
.style-metrics .sm .v { font-size: 22px; font-weight: 700; color: #1d4ed8; }
|
||||
.style-metrics .sm .l { font-size: 11px; color: #3b82f6; margin-top: 2px; }
|
||||
.cluster-table { width: 100%; border-collapse: collapse; font-size: 12px; margin-top: 6px; }
|
||||
.cluster-table th, .cluster-table td { padding: 5px 10px; text-align: center; border-bottom: 1px solid #dbeafe; }
|
||||
.cluster-table th { color: #1e40af; font-weight: 700; }
|
||||
.no-breakdown { font-size: 12px; color: #9ca3af; margin-top: 10px; }
|
||||
|
||||
.empty-state { text-align: center; padding: 60px 20px; color: #9ca3af; }
|
||||
.footer-note { font-size: 12px; color: #9ca3af; margin-top: 20px; line-height: 1.8; }
|
||||
.footer-note b { color: #6b7280; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.metrics-row { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>⏱️ API 耗时看板</h1>
|
||||
<p class="subtitle">按天统计各接口调用次数与耗时分布,多发型接口额外拆解「单次换发型耗时」与「整体耗时」</p>
|
||||
<div class="nav">
|
||||
<a href="/">← 返回首页</a>
|
||||
<a href="/admin/stats">实时请求统计</a>
|
||||
<a href="/docs">API 文档</a>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<label for="dateSelect">选择日期</label>
|
||||
<select id="dateSelect" onchange="loadDate()"></select>
|
||||
<button onclick="loadDate()">🔄 刷新</button>
|
||||
<span id="loadingHint"></span>
|
||||
</div>
|
||||
|
||||
<div id="content"></div>
|
||||
|
||||
<div class="footer-note">
|
||||
<div><b>说明:</b>「整个 API 耗时」为网关实测的请求总耗时(收到请求到返回响应),已排除网络传输本身的额外延迟。</div>
|
||||
<div><b>单次换发型耗时</b>为估算值:C端生发 / C端生发v2 / 发际线PNG生成 这几个接口,对每个勾选的发型会串行调用一次 ComfyUI 重绘,
|
||||
总耗时 ≈ 固定开销(人脸检测等,通常 < 1s)+ 单次换发型耗时 × 勾选的发型数量。日志未记录具体勾选了几个发型,
|
||||
这里基于当天所有请求耗时的分布,用迭代最小二乘回归拆解出「单次换发型耗时」与「固定开销」两个分量,供参考。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function fmtSeconds(ms) {
|
||||
if (ms == null) return '—';
|
||||
const s = ms / 1000;
|
||||
if (s < 60) return s.toFixed(1) + 's';
|
||||
return (s / 60).toFixed(1) + 'min';
|
||||
}
|
||||
function fmtSecondsRaw(s) {
|
||||
if (s == null) return '—';
|
||||
if (s < 60) return s.toFixed(1) + 's';
|
||||
return (s / 60).toFixed(1) + 'min';
|
||||
}
|
||||
|
||||
async function loadDates() {
|
||||
const r = await fetch('/admin/stats/dates');
|
||||
const data = await r.json();
|
||||
const sel = document.getElementById('dateSelect');
|
||||
sel.innerHTML = '';
|
||||
if (!data.dates || data.dates.length === 0) {
|
||||
sel.innerHTML = '<option>暂无数据</option>';
|
||||
return;
|
||||
}
|
||||
data.dates.forEach((d, i) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = d;
|
||||
opt.textContent = d + (i === 0 ? '(今天)' : '');
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
loadDate();
|
||||
}
|
||||
|
||||
async function loadDate() {
|
||||
const date = document.getElementById('dateSelect').value;
|
||||
if (!date) return;
|
||||
document.getElementById('loadingHint').textContent = '⏳ 加载中...';
|
||||
try {
|
||||
const r = await fetch('/admin/stats/daily?date=' + encodeURIComponent(date));
|
||||
const data = await r.json();
|
||||
render(data);
|
||||
document.getElementById('loadingHint').textContent = '✅ 已更新 ' + new Date().toLocaleTimeString();
|
||||
} catch (e) {
|
||||
document.getElementById('loadingHint').textContent = '❌ 加载失败: ' + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
function render(data) {
|
||||
const el = document.getElementById('content');
|
||||
if (data.error) {
|
||||
el.innerHTML = '<div class="empty-state">⚠ ' + data.error + '</div>';
|
||||
return;
|
||||
}
|
||||
if (!data.summary || data.summary.total === 0) {
|
||||
el.innerHTML = '<div class="empty-state">📭 ' + data.date + ' 当天没有调用记录</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const s = data.summary;
|
||||
let html = '';
|
||||
|
||||
// 汇总卡片
|
||||
html += '<div class="stats-grid">' +
|
||||
'<div class="stat-card"><div class="value">' + s.total + '</div><div class="label">当天请求总数</div></div>' +
|
||||
'<div class="stat-card ok"><div class="value">' + s.success_rate + '%</div><div class="label">成功率</div></div>' +
|
||||
'<div class="stat-card"><div class="value">' + fmtSeconds(s.avg_duration_ms) + '</div><div class="label">平均耗时/次</div></div>' +
|
||||
'<div class="stat-card"><div class="value">' + fmtSecondsRaw(s.total_duration_seconds) + '</div><div class="label">累计耗时</div></div>' +
|
||||
'<div class="stat-card"><div class="value">' + fmtSeconds(s.max_duration_ms) + '</div><div class="label">最长单次耗时</div></div>' +
|
||||
'</div>';
|
||||
|
||||
// 按小时分布
|
||||
if (data.hourly && data.hourly.length > 0) {
|
||||
const maxCnt = Math.max(...data.hourly.map(h => h.count));
|
||||
html += '<div class="hourly-wrap"><h2>📅 按小时请求量分布</h2><div class="hourly-bars">';
|
||||
data.hourly.forEach(h => {
|
||||
const pct = Math.max(4, Math.round(h.count / maxCnt * 100));
|
||||
html += '<div class="hourly-bar" style="height:' + pct + '%"><div class="cnt">' + h.count + '</div></div>';
|
||||
});
|
||||
html += '</div><div class="hourly-labels">';
|
||||
data.hourly.forEach(h => {
|
||||
html += '<span>' + String(h.hour).padStart(2, '0') + '时</span>';
|
||||
});
|
||||
html += '</div></div>';
|
||||
}
|
||||
|
||||
// 按接口
|
||||
data.endpoints.forEach(ep => {
|
||||
const badge = ep.interface_num != null ? ('接口' + ep.interface_num) : '未知接口';
|
||||
html += '<div class="ep-card">' +
|
||||
'<div class="ep-header">' +
|
||||
'<span class="ep-badge">' + badge + '</span>' +
|
||||
'<span class="ep-name">' + ep.interface_name + '</span>' +
|
||||
'<span class="ep-path">' + ep.path + '</span>' +
|
||||
'<span class="ep-success">调用 <b>' + ep.count + '</b> 次 · 成功率 <b style="color:' + (ep.success_rate >= 99 ? '#059669' : '#d97706') + '">' + ep.success_rate + '%</b></span>' +
|
||||
'</div>' +
|
||||
'<div class="ep-body">' +
|
||||
'<div class="metrics-row">' +
|
||||
'<div class="metric"><div class="v">' + fmtSeconds(ep.avg_duration_ms) + '</div><div class="l">平均耗时</div></div>' +
|
||||
'<div class="metric"><div class="v">' + fmtSeconds(ep.median_duration_ms) + '</div><div class="l">中位数</div></div>' +
|
||||
'<div class="metric"><div class="v">' + fmtSeconds(ep.p95_duration_ms) + '</div><div class="l">P95</div></div>' +
|
||||
'<div class="metric"><div class="v">' + fmtSeconds(ep.min_duration_ms) + '</div><div class="l">最快</div></div>' +
|
||||
'<div class="metric"><div class="v">' + fmtSeconds(ep.max_duration_ms) + '</div><div class="l">最慢</div></div>' +
|
||||
'<div class="metric"><div class="v">' + fmtSecondsRaw(ep.total_duration_seconds) + '</div><div class="l">累计耗时</div></div>' +
|
||||
'</div>';
|
||||
|
||||
if (ep.per_style) {
|
||||
const ps = ep.per_style;
|
||||
html += '<div class="style-box">' +
|
||||
'<div class="title">✂️ 单次换发型耗时拆解(估算)</div>' +
|
||||
'<div class="style-metrics">' +
|
||||
'<div class="sm"><div class="v">' + ps.per_style_seconds.toFixed(1) + 's</div><div class="l">单次换发型耗时</div></div>' +
|
||||
'<div class="sm"><div class="v">' + ps.fixed_overhead_seconds.toFixed(1) + 's</div><div class="l">固定开销</div></div>' +
|
||||
'</div>' +
|
||||
'<div class="formula">整体 API 耗时 ≈ ' + ps.fixed_overhead_seconds.toFixed(1) + 's + ' + ps.per_style_seconds.toFixed(1) + 's × 发型数量</div>' +
|
||||
'<table class="cluster-table"><thead><tr><th>推断发型数</th><th>样本数</th><th>平均耗时</th></tr></thead><tbody>';
|
||||
ps.clusters.forEach(c => {
|
||||
html += '<tr><td>' + c.styles + '</td><td>' + c.count + '</td><td>' + c.avg_seconds.toFixed(1) + 's</td></tr>';
|
||||
});
|
||||
html += '</tbody></table></div>';
|
||||
} else if (['/api/v1/hair/grow', '/api/v1/hair/grow-v2', '/api/v1/hairline/generate'].includes(ep.path)) {
|
||||
html += '<div class="no-breakdown">ℹ️ 当天该接口耗时分布过于集中,样本不足以拆解出单次换发型耗时(换个天试试,或等积累更多调用)</div>';
|
||||
}
|
||||
|
||||
html += '</div></div>';
|
||||
});
|
||||
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
loadDates();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+16
-40
@@ -57,7 +57,6 @@
|
||||
<a href="#if4">接口4</a>
|
||||
<a href="#if5">接口5</a>
|
||||
<a href="#if6">接口6</a>
|
||||
<a href="#if7">接口7</a>
|
||||
<a href="#errors">错误码</a>
|
||||
<a href="#test">在线测试</a>
|
||||
</div>
|
||||
@@ -109,6 +108,8 @@
|
||||
<tr><td><code>landmarks</code></td><td>object</td><td>5 个关键点像素坐标:hair_top/hairline/brow_center/nose_bottom/chin_tip</td></tr>
|
||||
<tr><td><code>hairline_source</code></td><td>string</td><td>发际线来源:<code>"segmentation"</code>(真实分割,可信度高)/ <code>"estimated"</code>(比例估算,可信度低)</td></tr>
|
||||
<tr><td><code>head_pose</code></td><td>object</td><td>头部姿态角度:<code>{ yaw, pitch, roll }</code>(度),接近 0 表示正面照</td></tr>
|
||||
<tr><td><code>left_position</code></td><td>object</td><td>MediaPipe 21 号关键点坐标(左脸定位点),原图像素:<code>{ x: number, y: number }</code></td></tr>
|
||||
<tr><td><code>right_position</code></td><td>object</td><td>MediaPipe 251 号关键点坐标(右脸定位点,与 21 号镜像),原图像素:<code>{ x: number, y: number }</code></td></tr>
|
||||
</table>
|
||||
|
||||
<p style="margin-top:12px;font-size:12px;color:#64748b">💡 前端把标注图叠加到原图上即可呈现测量效果(标注图白色线条 #FFFFFF,透明底)。</p>
|
||||
@@ -189,17 +190,17 @@ const { code, data } = await res.json();
|
||||
<div class="card" id="if4">
|
||||
<h2>4. 用户特征分析 <span class="badge post">POST</span> <code>/api/v1/face/features</code></h2>
|
||||
<div class="card-body">
|
||||
<p class="desc">上传照片 → 火山方舟豆包视觉模型分析 → 返回几十项面部特征(脸型/眉形/肤色/四季色彩…)。</p>
|
||||
<p class="desc">上传照片 → 火山方舟豆包视觉模型分析 → 返回固定 6 项面部特征(脸型/眉形/面部年龄/动静类型/性别/基因风格)。</p>
|
||||
|
||||
<p><strong>入参</strong>:image_file / image_url / image_base64 三选一。无其他参数。</p>
|
||||
|
||||
<p style="margin-top:12px"><strong>data 字段</strong></p>
|
||||
<table>
|
||||
<tr><th>字段</th><th>类型</th><th>说明</th></tr>
|
||||
<tr><td><code>features</code></td><td>string</td><td><strong>JSON 字符串</strong>(不是对象!客户端需 <code>JSON.parse()</code>)</td></tr>
|
||||
<tr><td><code>features</code></td><td>string</td><td><strong>JSON 字符串</strong>(不是对象!客户端需 <code>JSON.parse()</code>)。解析后得到<strong>固定 6 个英文字段</strong></td></tr>
|
||||
</table>
|
||||
|
||||
<p style="margin-top:8px"><strong>features 英文优先字段</strong>(其余中文字段同时返回,共~42个):</p>
|
||||
<p style="margin-top:8px"><strong>features 字段</strong>(固定返回 6 个):</p>
|
||||
<table>
|
||||
<tr><th>字段</th><th>说明</th><th>字段</th><th>说明</th></tr>
|
||||
<tr><td>face_shape</td><td>脸型</td><td>eyebrow_shape</td><td>眉形</td></tr>
|
||||
@@ -214,7 +215,7 @@ const { code, data } = await res.json();
|
||||
const { code, data } = await res.json();
|
||||
const features = JSON.parse(data.features); // ← 注意:data.features 是字符串!
|
||||
console.log(features.face_shape); // "鹅蛋脸"
|
||||
console.log(features['四季色彩季型']); // "冷夏型"(中文字段也保留)</pre>
|
||||
console.log(features.gene_style); // "自然型"</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -230,12 +231,13 @@ console.log(features['四季色彩季型']); // "冷夏型"(中文字段也保
|
||||
<tr><td>image_file / image_url / image_base64</td><td>—</td><td>三选一</td><td>用户正面照</td></tr>
|
||||
<tr><td>gender</td><td>string</td><td>✅ 必填</td><td><code>"male"</code> / <code>"female"</code></td></tr>
|
||||
<tr><td>hair_style</td><td>string</td><td>✅ 必填</td><td>发型序号,逗号分隔多选(如 <code>1,2,3</code>)。缺失/越界返回 1007</td></tr>
|
||||
<tr><td>generate_grow_image</td><td>bool</td><td>否</td><td>是否生成生发效果图(ComfyUI 生发,全流程最耗时),默认 <code>true</code>。传 <code>false</code> 时跳过生发,各发型 <code>grown_image_url</code> 恒为 <code>null</code>,仅返回三档发际线叠图与中心点,大幅降低耗时</td></tr>
|
||||
</table>
|
||||
|
||||
<p style="margin-top:12px"><strong>data 字段</strong></p>
|
||||
<table>
|
||||
<tr><th>字段</th><th>类型</th><th>说明</th></tr>
|
||||
<tr><td><code>hairline_images[]</code></td><td>object[]</td><td>选中发型列表,每项含 <code>hairline_type</code>、<code>image_middle_url</code>/<code>image_high_url</code>/<code>image_low_url</code> 三档<strong>透明 PNG 叠图</strong>(仅曲线,需叠加原图)、<code>grown_image_url</code> 生发图(完整人像,失败为 null)、<code>order</code></td></tr>
|
||||
<tr><td><code>hairline_images[]</code></td><td>object[]</td><td>选中发型列表,每项含 <code>hairline_type</code>、<code>image_middle_url</code>/<code>image_high_url</code>/<code>image_low_url</code> 三档<strong>透明 PNG 叠图</strong>(仅曲线,需叠加原图)、<code>grown_image_url</code> 生发图(完整人像,失败或 <code>generate_grow_image=false</code> 时为 null)、<code>order</code></td></tr>
|
||||
<tr><td><code>best_hairline_center_point</code></td><td>object \| null</td><td>首个选中发型 <strong>middle 档</strong>发际线中心点像素坐标 <code>{ x: number, y: number }</code></td></tr>
|
||||
<tr><td><code>high_hairline_center_point</code></td><td>object \| null</td><td>同上,<strong>high 档</strong>发际线中点(发际线偏高,y 更小)</td></tr>
|
||||
<tr><td><code>low_hairline_center_point</code></td><td>object \| null</td><td>同上,<strong>low 档</strong>发际线中点(发际线偏低,y 更大)</td></tr>
|
||||
@@ -251,6 +253,8 @@ console.log(features['四季色彩季型']); // "冷夏型"(中文字段也保
|
||||
<tr><td><code>landmarks</code></td><td>object</td><td>5 个关键点像素坐标:hair_top/hairline/brow_center/nose_bottom/chin_tip</td></tr>
|
||||
<tr><td><code>hairline_source</code></td><td>string</td><td>发际线来源:<code>"segmentation"</code>(真实分割)/ <code>"estimated"</code>(比例估算)</td></tr>
|
||||
<tr><td><code>head_pose</code></td><td>object</td><td>头部姿态角度:<code>{ yaw, pitch, roll }</code>(度)</td></tr>
|
||||
<tr><td><code>left_position</code></td><td>object</td><td>MediaPipe 21 号关键点坐标(左脸定位点),原图像素:<code>{ x: number, y: number }</code></td></tr>
|
||||
<tr><td><code>right_position</code></td><td>object</td><td>MediaPipe 251 号关键点坐标(右脸定位点,与 21 号镜像),原图像素:<code>{ x: number, y: number }</code></td></tr>
|
||||
</table>
|
||||
|
||||
<p style="margin-top:12px;font-size:12px;color:#64748b">💡 前端无需额外请求接口1 即可拿到四庭七眼测量数值;<code>face_measure</code> 为 <code>null</code> 时(角度过大/无人脸等)仅隐藏测量区块,发际线结果照常展示。</p>
|
||||
@@ -282,41 +286,12 @@ console.log(features['四季色彩季型']); // "冷夏型"(中文字段也保
|
||||
<tr><td><code>four_courts</code></td><td>object</td><td>三庭:upper/middle/lower,各含 _cm 和 ratios(<strong>无 top_court</strong>)</td></tr>
|
||||
<tr><td><code>seven_eyes</code></td><td>object</td><td>七眼:<code>eye_width_cm</code>/<code>face_width_cm</code>/<code>inter_eye_distance_cm</code> + <code>ratios</code> + <strong><code>eye2</code>~<code>eye6</code></strong>(左脸颊/左眼/两眼间距/右眼/右脸颊,5 段宽度 cm;<strong>无 eye1/eye7</strong>)</td></tr>
|
||||
<tr><td><code>landmarks</code></td><td>object</td><td>4 个关键点:hairline/brow_center/nose_bottom/chin_tip(<strong>无 hair_top</strong>)</td></tr>
|
||||
<tr><td><code>left_position</code></td><td>object</td><td>MediaPipe 21 号关键点坐标(左脸定位点),原图像素:<code>{ x: number, y: number }</code></td></tr>
|
||||
<tr><td><code>right_position</code></td><td>object</td><td>MediaPipe 251 号关键点坐标(右脸定位点,与 21 号镜像),原图像素:<code>{ x: number, y: number }</code></td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ======== 接口7 ======== -->
|
||||
<div class="card" id="if7">
|
||||
<h2>7. C端生发 v2 <span class="badge post">POST</span> <code>/api/v1/hair/grow-v2</code> <span class="badge warn">v2</span></h2>
|
||||
<div class="card-body">
|
||||
<p class="desc">功能与<a href="#if2">接口2</a>完全一致,仅 ComfyUI 工作流不同——使用 <code>add_hair2.json</code> 替代 <code>add_hair.json</code>(Flux-2 Klein 9b)。</p>
|
||||
|
||||
<p><strong>入参</strong></p>
|
||||
<table>
|
||||
<tr><th>参数</th><th>类型</th><th>必填</th><th>说明</th></tr>
|
||||
<tr><td>image_file / image_url / image_base64</td><td>—</td><td>三选一</td><td>用户正面照</td></tr>
|
||||
<tr><td>gender</td><td>string</td><td>✅ 必填</td><td><code>"male"</code> / <code>"female"</code></td></tr>
|
||||
<tr><td>hair_style</td><td>int</td><td>✅ 必填</td><td>发型序号。female: 1~5,male: 1~4</td></tr>
|
||||
</table>
|
||||
|
||||
<p style="margin-top:12px"><strong>data.results[] 元素</strong>(同接口2)</p>
|
||||
<table>
|
||||
<tr><th>字段</th><th>类型</th><th>说明</th></tr>
|
||||
<tr><td><code>image_url</code></td><td>string</td><td>发际线曲线<strong>透明 PNG</strong>(仅曲线,需叠加原图显示,同接口2)</td></tr>
|
||||
<tr><td><code>grown_image_url</code></td><td>string</td><td>生发后效果图(完整人像)⚠ 可空</td></tr>
|
||||
<tr><td><code>hairline_type</code></td><td>string</td><td>发际线类型 key</td></tr>
|
||||
<tr><td><code>order</code></td><td>int</td><td>排序</td></tr>
|
||||
</table>
|
||||
|
||||
<p style="margin-top:8px;font-size:12px;color:#64748b">
|
||||
Female 5 种:ellipse/flower/heart/straight/wave |
|
||||
Male 4 种:ellipse/m/straight/inverse_arc<br>
|
||||
⚠ 工作流: add_hair2.json(Flux-2 Klein 9b),输入节点 26,输出节点 75。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ======== 错误码 ======== -->
|
||||
<div class="card" id="errors">
|
||||
<h2>⚠ 错误码</h2>
|
||||
@@ -326,12 +301,14 @@ console.log(features['四季色彩季型']); // "冷夏型"(中文字段也保
|
||||
<tr><td>1001</td><td>无法识别人像</td><td>未检测到人脸</td></tr>
|
||||
|
||||
<tr><td>1003</td><td>角度问题,非正面照</td><td>非正面 / 角度过大</td></tr>
|
||||
<tr><td>1004</td><td>gender 必填且只能为 male / female</td><td>接口2/5 的 <code>gender</code> 缺失或非法</td></tr>
|
||||
<tr><td>1005</td><td>检测到多张人脸</td><td>仅支持单人</td></tr>
|
||||
|
||||
<tr><td>1007</td><td>图片参数错误 / 后端不可用</td><td>参数传错 / 服务繁忙请稍后重试</td></tr>
|
||||
<tr><td>1008</td><td>图片格式不支持</td><td>非 JPG/PNG / base64 解码失败</td></tr>
|
||||
<tr><td>1009</td><td>未授权</td><td>缺少或错误的 <code>X-Internal-Token</code>(<code>/api/*</code> 路径鉴权)</td></tr>
|
||||
</table>
|
||||
<p style="font-size:12px;color:#94a3b8;margin-top:8px">1004 已废弃(接口2 不再自动判性别,改由客户端传 gender 参数)。</p>
|
||||
<p style="font-size:12px;color:#94a3b8;margin-top:8px">注:1004 仍在使用(接口2/5 的 gender 校验);接口7(grow-v2)已弃用,请改用接口2。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -344,10 +321,9 @@ console.log(features['四季色彩季型']); // "冷夏型"(中文字段也保
|
||||
<tr><td>1. 四庭七眼</td><td><a href="/static/test_interface1.html" class="link">/static/test_interface1.html</a></td><td>上传照片 → 原图+标注叠加,底图/标注开关,指标卡片</td></tr>
|
||||
<tr><td>2. C端生发</td><td><a href="/static/test_interface2.html" class="link">/static/test_interface2.html</a></td><td>上传+性别 → 方案一覧(原图/叠加/生发),双图对比</td></tr>
|
||||
<tr><td>3. B端生发</td><td><a href="/static/test_interface3.html" class="link">/static/test_interface3.html</a></td><td>划线图上传 → 生发效果图</td></tr>
|
||||
<tr><td>4. 用户特征</td><td><a href="/static/test_interface4.html" class="link">/static/test_interface4.html</a></td><td>上传照片 → 42项面部特征表格 + 原始JSON</td></tr>
|
||||
<tr><td>4. 用户特征</td><td><a href="/static/test_interface4.html" class="link">/static/test_interface4.html</a></td><td>上传照片 → 6项面部特征 + 原始JSON</td></tr>
|
||||
<tr><td>5. 发际线PNG</td><td><a href="/static/test_interface5.html" class="link">/static/test_interface5.html</a></td><td>上传+性别 → 发际线方案+中心点坐标</td></tr>
|
||||
<tr><td>6. 四庭七眼 v2</td><td><a href="/static/test_interface6.html" class="link">/static/test_interface6.html</a></td><td>同接口1,去顶庭 · 竖线发际线→下巴 · 无头部端线</td></tr>
|
||||
<tr><td>7. C端生发 v2</td><td><a href="/static/test_interface7.html" class="link">/static/test_interface7.html</a></td><td>同接口2,使用 add_hair2.json 工作流(Flux-2 Klein 9b)</td></tr>
|
||||
</table>
|
||||
<p style="font-size:12px;color:#94a3b8;margin-top:12px">
|
||||
完整 API 文档:<a href="/docs" class="link">/docs</a>(Swagger UI)
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
</div>
|
||||
<div class="upload-row" style="margin-top:10px">
|
||||
<label style="font-size:13px;font-weight:600;white-space:nowrap">重绘提示词</label>
|
||||
<input type="text" id="localTestPrompt" value="填充遮罩区域的头发,皮肤加一点磨皮" style="flex:1;min-width:200px;padding:8px;border:1px solid #ddd;border-radius:8px">
|
||||
<input type="text" id="localTestPrompt" value="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜" style="flex:1;min-width:200px;padding:8px;border:1px solid #ddd;border-radius:8px">
|
||||
</div>
|
||||
<div class="upload-row" style="margin-top:10px">
|
||||
<label style="font-size:13px;font-weight:600;white-space:nowrap">X-Internal-Token</label>
|
||||
@@ -354,7 +354,7 @@ function dataUriToBlob(dataUri) {
|
||||
|
||||
async function runLocalRedraw() {
|
||||
if (!_finalDataUri || !_maskDataUri) { setStatus('缺少 final 或遮罩,请先生成', 'error'); return; }
|
||||
const prompt = $('localTestPrompt').value.trim() || '填充遮罩区域的头发,皮肤加一点磨皮';
|
||||
const prompt = $('localTestPrompt').value.trim() || '填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜';
|
||||
const btn = $('redrawBtn');
|
||||
btn.disabled = true; btn.textContent = '⏳ 重绘中...';
|
||||
flog('===== 调后端重绘 =====', 'info');
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
</div>
|
||||
<div class="upload-row" style="margin-top:10px">
|
||||
<label style="font-size:13px;font-weight:600;white-space:nowrap">重绘提示词</label>
|
||||
<input type="text" id="localTestPrompt" value="填充遮罩区域的头发,皮肤加一点磨皮" style="flex:1;min-width:200px;padding:8px;border:1px solid #ddd;border-radius:8px">
|
||||
<input type="text" id="localTestPrompt" value="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜" style="flex:1;min-width:200px;padding:8px;border:1px solid #ddd;border-radius:8px">
|
||||
</div>
|
||||
<div class="params">
|
||||
<div class="pf">
|
||||
@@ -169,7 +169,7 @@ async function submitTest() {
|
||||
|
||||
async function runLocalRedraw() {
|
||||
if (!_finalDataUri || !_maskDataUri) { setStatus('缺少 final 或遮罩,请先生成', 'error'); return; }
|
||||
const prompt = $('localTestPrompt').value.trim() || '填充遮罩区域的头发,皮肤加一点磨皮';
|
||||
const prompt = $('localTestPrompt').value.trim() || '填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜';
|
||||
const btn = $('redrawBtn');
|
||||
btn.disabled = true; btn.textContent = '⏳ 重绘中...';
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@
|
||||
<div class="hint">JPG/PNG | 生发图生成较慢(数十秒~数分钟),请耐心等待</div>
|
||||
<div style="margin-top:10px;display:flex;align-items:center;gap:8px">
|
||||
<label style="font-size:13px;font-weight:600;color:#374151;white-space:nowrap">💬 提示词</label>
|
||||
<input type="text" id="promptInput" value="补充遮罩区域的头发,加一点美颜" style="flex:1;padding:8px 12px;border:1px solid #d1d5db;border-radius:6px;font-size:13px;max-width:500px">
|
||||
<input type="text" id="promptInput" value="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜" style="flex:1;padding:8px 12px;border:1px solid #d1d5db;border-radius:6px;font-size:13px;max-width:500px">
|
||||
</div>
|
||||
<div id="statusBar" class="status hidden"></div>
|
||||
</div>
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
</div>
|
||||
<div class="upload-group" style="margin-top:14px">
|
||||
<div class="label">💬 提示词(prompt)</div>
|
||||
<input type="text" id="promptInput" value="补充遮罩区域的头发,加一点美颜" style="width:100%;padding:8px 12px;border:1px solid #d1d5db;border-radius:8px;font-size:14px;max-width:500px">
|
||||
<input type="text" id="promptInput" value="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜" style="width:100%;padding:8px 12px;border:1px solid #d1d5db;border-radius:8px;font-size:14px;max-width:500px">
|
||||
</div>
|
||||
<div style="margin-top:14px;display:flex;gap:12px;align-items:center">
|
||||
<button class="btn btn-primary" id="submitBtn" onclick="submitTest()">🚀 提交</button>
|
||||
|
||||
@@ -113,6 +113,12 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>生发效果图</label>
|
||||
<label class="checkbox-inline" style="font-weight:normal;display:flex;align-items:center;gap:6px">
|
||||
<input type="checkbox" id="genGrowImg" checked> generate_grow_image(默认开;关闭后跳过最耗时的生发,仅返回三档叠图与中心点)
|
||||
</label>
|
||||
</div>
|
||||
<button class="btn btn-primary" id="submitBtn" onclick="submitTest()">🚀 提交</button>
|
||||
<button class="btn btn-outline btn-sm" onclick="clearResults()">清除</button>
|
||||
</div>
|
||||
@@ -206,7 +212,7 @@ async function submitTest() {
|
||||
$('submitBtn').disabled = true; $('submitBtn').textContent = '⏳ ...';
|
||||
setStatus('请求中...', 'info'); $('resultsArea').classList.add('hidden');
|
||||
|
||||
const fd = new FormData(); fd.append('image_file', f); fd.append('gender', $('gender').value); fd.append('hair_style', checked.join(','));
|
||||
const fd = new FormData(); fd.append('image_file', f); fd.append('gender', $('gender').value); fd.append('hair_style', checked.join(',')); fd.append('generate_grow_image', $('genGrowImg').checked ? 'true' : 'false');
|
||||
const _reqStart = performance.now();
|
||||
try {
|
||||
const r = await fetch(API_BASE + '/api/v1/hairline/generate', { method:'POST', headers: { 'X-Internal-Token': 'dev-shared-secret-2026' }, body:fd });
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
<div class="hint">JPG/PNG | 生发图生成较慢(数十秒~数分钟),请耐心等待 | 工作流: add_hair2.json</div>
|
||||
<div style="margin-top:10px;display:flex;align-items:center;gap:8px">
|
||||
<label style="font-size:13px;font-weight:600;color:#374151;white-space:nowrap">💬 提示词</label>
|
||||
<input type="text" id="promptInput" value="补充遮罩区域的头发,加一点美颜" style="flex:1;padding:8px 12px;border:1px solid #d1d5db;border-radius:6px;font-size:13px;max-width:500px">
|
||||
<input type="text" id="promptInput" value="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜" style="flex:1;padding:8px 12px;border:1px solid #d1d5db;border-radius:6px;font-size:13px;max-width:500px">
|
||||
</div>
|
||||
<div id="statusBar" class="status hidden"></div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user