Files
hair/static/test_interface2.html
T
xsl 5101bb5f6b feat(接口2): 测试页暴露重绘分辨率选项 + 分辨率对比测试套件
- test_interface2.html: 新增「重绘分辨率」下拉(默认896/1024/768/640/不缩图0),
  仅 female 生效,append redraw_max_side 字段;male 时自动隐藏
- 新增分辨率对比测试工具 (image/compare_test, image/res_test):
  - batch_test.py: 串行批量测试脚本,支持断点续跑
  - gen_report.py: 生成自包含 HTML 对比报告(速度色阶+缩略图+点击放大)
- 两轮测试结果:
  - v1: 4图×5发型×2档=40次 (compare_test)
  - v2: 5图×3发型×5档=75次 (res_test)
  - 结论: 大图(长边>1600)原图直送比896档慢~3倍; 中小图各档差异小
- .gitignore: 忽略测试结果图/原图副本/运行日志(out/, *.log, progress.json)
2026-07-26 22:06:27 +08:00

295 lines
15 KiB
HTML
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.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>接口2 — C端生发 测试页</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: 1400px; margin: 0 auto; padding: 24px; }
h1 { font-size: 22px; margin-bottom: 6px; }
.subtitle { color: #888; font-size: 13px; margin-bottom: 24px; }
.card { background: #fff; border-radius: 12px; box-shadow: 0 1px 4px rgba(0,0,0,.06); margin-bottom: 20px; }
.card-header { font-weight: 700; font-size: 14px; padding: 14px 18px; border-bottom: 1px solid #f0f0f0; background: #fafafa; display: flex; justify-content: space-between; align-items: center; }
.card-body { padding: 18px; }
.upload-row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
.file-input { flex: 1; min-width: 200px; }
.file-input input[type=file] { width: 100%; padding: 8px; border: 2px dashed #ddd; border-radius: 8px; cursor: pointer; }
.form-group { display: flex; align-items: center; gap: 6px; white-space: nowrap; }
.form-group label { font-size: 13px; font-weight: 600; color: #374151; }
.form-group select { padding: 8px 12px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 14px; }
.hair-checkboxes { display: flex; flex-wrap: wrap; gap: 4px 14px; align-items: center; }
.hair-checkboxes label { font-size: 13px; font-weight: 500; color: #374151; cursor: pointer; display: inline-flex; align-items: center; gap: 3px; white-space: nowrap; }
.hair-checkboxes label input[type=checkbox] { cursor: pointer; }
.checkbox-actions { display: flex; gap: 6px; margin-left: 4px; }
.checkbox-actions button { font-size: 11px; padding: 2px 8px; border-radius: 4px; border: 1px solid #d1d5db; background: #f9fafb; cursor: pointer; }
.checkbox-actions button:hover { background: #e5e7eb; }
.btn { padding: 10px 28px; border: none; border-radius: 8px; font-size: 15px; cursor: pointer; font-weight: 600; transition: .2s; }
.btn-primary { background: #2563eb; color: #fff; }
.btn-primary:hover { background: #1d4ed8; }
.btn-primary:disabled { background: #93c5fd; cursor: not-allowed; }
.btn-sm { padding: 6px 14px; font-size: 13px; }
.btn-outline { background: #fff; border: 1px solid #d1d5db; color: #374151; }
.btn-outline:hover { background: #f9fafb; }
.hint { font-size: 12px; color: #9ca3af; margin-top: 8px; }
.status { padding: 10px 16px; border-radius: 8px; font-size: 14px; margin-bottom: 16px; display: none; }
.status.info { background: #dbeafe; color: #1e40af; display: block; }
.status.error { background: #fee2e2; color: #991b1b; display: block; }
.status.success { background: #d1fae5; color: #065f46; display: block; }
.results-layout { display: flex; gap: 24px; }
.col-main { flex: 1.8; min-width: 0; }
.col-side { flex: 1; min-width: 0; }
/* 方案列表 — 每个方案一个横向卡片 */
.scheme-card { background: #fff; border-radius: 10px; box-shadow: 0 1px 4px rgba(0,0,0,.06); margin-bottom: 14px; overflow: hidden; }
.scheme-header { padding: 10px 16px; background: #fafafa; border-bottom: 1px solid #f0f0f0; display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 700; }
.scheme-header .badge { background: #2563eb; color: #fff; padding: 2px 10px; border-radius: 10px; font-size: 11px; }
.scheme-header .tag { font-size: 11px; color: #6b7280; }
.scheme-imgs { display: flex; gap: 12px; padding: 12px 16px; }
.scheme-imgs .img-slot { flex: 1; min-width: 0; text-align: center; }
.scheme-imgs .img-slot .label { font-size: 11px; font-weight: 600; color: #6b7280; margin-bottom: 6px; text-transform: uppercase; letter-spacing: .3px; }
.scheme-imgs .img-slot .label .dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; margin-right: 4px; vertical-align: middle; }
.scheme-imgs .img-slot .label .dot.preview { background: #f59e0b; }
.scheme-imgs .img-slot .label .dot.grown { background: #059669; }
.scheme-imgs .img-slot .thumb { background: #222; border-radius: 6px; overflow: hidden; line-height: 0; }
.scheme-imgs .img-slot .thumb img { width: 100%; max-height: 220px; object-fit: contain; display: block; }
.scheme-imgs .img-slot .na { color: #d1d5db; font-size: 12px; padding: 30px 0; background: #f9fafb; border-radius: 6px; }
/* 原图 + 透明 PNG 叠加显示 */
.img-stack { position: relative; display: inline-block; line-height: 0; }
.img-stack .layer-base { display: block; width: 100%; max-height: 220px; object-fit: contain; }
.img-stack .layer-anno { position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: contain; }
.json-panel { max-height: 550px; overflow: auto; }
.json-content { padding: 14px 16px; font-family: "SF Mono", "Fira Code", monospace; font-size: 12px; line-height: 1.6; white-space: pre-wrap; word-break: break-all; }
.hidden { display: none !important; }
</style>
<script src="/static/img_downscale.js?v=2"></script>
</head>
<body>
<div class="container">
<h1>💇 接口2 — C端生发 测试</h1>
<p class="subtitle">POST /api/v1/hair/grow &nbsp;|&nbsp; 正面照 + 性别 + 发型多选 → 所选发型的预览图与生发图</p>
<div class="card">
<div class="card-body">
<div class="upload-row">
<div class="file-input"><input type="file" id="imageFile" accept="image/jpeg,image/png,.jpg,.jpeg,.png"></div>
<div class="form-group">
<label>性别</label>
<select id="gender" onchange="onGenderChange()"><option value="female" selected>👩 Female</option><option value="male">👨 Male</option></select>
</div>
<div class="form-group" style="align-items:flex-start">
<label style="padding-top:3px">发型</label>
<div>
<div class="hair-checkboxes" id="hairStyleGroup"></div>
<div class="checkbox-actions" style="margin-top:4px">
<button type="button" onclick="selectAllHair(true)">全选</button>
<button type="button" onclick="selectAllHair(false)">全不选</button>
</div>
</div>
</div>
<div class="form-group">
<label>inpaint 遮罩</label>
<select id="useMask">
<option value="true" selected>✅ 启用(默认)</option>
<option value="false">⬜ 关闭 — 空遮罩,不烧模板线</option>
</select>
</div>
<div class="form-group" id="redrawSideGroup">
<label>重绘分辨率</label>
<select id="redrawMaxSide" title="仅 Female 生效:ComfyUI 重绘前把送图长边压到该像素。值越小越快越省显存,细节越少;0=原图直送">
<option value="" selected>默认 896</option>
<option value="1024">1024(高清,慢)</option>
<option value="768">768(快)</option>
<option value="640">640(最快,糊)</option>
<option value="0">0 — 不缩图(原图直送)</option>
</select>
</div>
<button class="btn btn-primary" id="submitBtn" onclick="submitTest()">🚀 提交</button>
<button class="btn btn-outline btn-sm" onclick="clearResults()">清除</button>
</div>
<div class="hint">JPG/PNG &nbsp;|&nbsp; 生发图生成较慢(数十秒~数分钟),请耐心等待</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">
</div>
<div id="statusBar" class="status hidden"></div>
</div>
</div>
<div class="results-layout hidden" id="resultsArea">
<div class="col-main">
<div class="card">
<div class="card-header"><span>🗂️ 生发结果</span></div>
<div class="card-body" id="schemesContainer"></div>
</div>
</div>
<div class="col-side">
<div class="card">
<div class="card-header"><span>📋 JSON</span><button class="btn btn-outline btn-sm" onclick="copyJson()">📋 复制</button></div>
<div class="json-panel"><pre class="json-content" id="jsonContent"></pre></div>
</div>
</div>
</div>
</div>
<script>
const API_BASE = window.location.origin;
let _origUrl = '';
const TYPE_LABELS = {
ellipse:'椭圆形', flower:'花瓣形', heart:'心形', straight:'直线形', wave:'波浪形', m:'M形', inverse_arc:'倒弧形'
};
function $(id) { return document.getElementById(id); }
function setStatus(t, type) { const b=$('statusBar'); b.textContent=t; b.className='status '+type; }
async function submitTest() {
let f = $('imageFile').files[0];
if (!f) { setStatus('请选择图片', 'error'); return; }
f = await window.downscaleImageFile(f);
_origUrl = URL.createObjectURL(f);
$('submitBtn').disabled = true; $('submitBtn').textContent = '⏳ 请求中...';
setStatus('⏳ 请求中(生发图生成较慢,请耐心)...', 'info'); $('resultsArea').classList.add('hidden');
const checked = [...document.querySelectorAll('#hairStyleGroup input:checked')].map(cb => cb.value);
if (checked.length === 0) { setStatus('请至少勾选一个发型', 'error'); $('submitBtn').disabled = false; $('submitBtn').textContent = '🚀 提交'; return; }
const fd = new FormData(); fd.append('image_file', f); fd.append('gender', $('gender').value);
fd.append('hair_style', checked.join(','));
fd.append('use_mask', $('useMask').value);
fd.append('prompt', $('promptInput').value);
// 重绘分辨率:仅 female 传给后端(male 路径不接受此参数)
if ($('gender').value === 'female') {
const side = $('redrawMaxSide').value;
if (side !== '') fd.append('redraw_max_side', side); // 空串=不传→后端默认896
}
const _reqStart = performance.now();
try {
const r = await fetch(API_BASE + '/api/v1/hair/grow', { method:'POST', headers:{ 'X-Internal-Token': 'dev-shared-secret-2026' }, body:fd });
const json = await r.json();
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
$('jsonContent').textContent = JSON.stringify(json, null, 2);
$('resultsArea').classList.remove('hidden');
if (json.code === 0) {
const results = json.data.results || [];
setStatus('✅ ' + results.length + ' 个方案 (' + _elapsed + 's)', 'success');
renderSchemes(results);
} else {
setStatus('❌ (' + _elapsed + 's) code=' + json.code + ' ' + json.message, 'error');
$('schemesContainer').innerHTML = '<div style="color:#9ca3af">无结果</div>';
}
} catch(e) {
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
setStatus('❌ (' + _elapsed + 's) ' + e.message, 'error'); $('resultsArea').classList.remove('hidden'); }
finally { $('submitBtn').disabled = false; $('submitBtn').textContent = '🚀 提交'; }
}
function renderSchemes(results) {
let html = '';
results.forEach((r, i) => {
const lb = TYPE_LABELS[r.hairline_type] || r.hairline_type;
// 兼容网关 *_url 与 worker *_base64(原始或 data URI
const overlaySrc = resolveImgSrc(r.image_url, r.image_base64, 'image/png');
const grownSrc = resolveImgSrc(r.grown_image_url, r.grown_image_base64, 'image/jpeg');
// 原图
const origSlot = '<div class="img-slot">'+
'<div class="label">📷 原图</div>'+
'<div class="thumb"><img src="'+_origUrl+'" alt="原图"></div></div>';
// 发际线叠加(原图 + 透明 PNG 叠加显示)
const overlaySlot = '<div class="img-slot">'+
'<div class="label"><span class="dot preview"></span>原图+发际线叠加</div>'+
'<div class="thumb"><div class="img-stack">'+
'<img class="layer-base" src="'+_origUrl+'" alt="原图">'+
'<img class="layer-anno" src="'+overlaySrc+'" alt="叠加">'+
'</div></div></div>';
// 生发效果
let grownSlot;
if (grownSrc) {
grownSlot = '<div class="img-slot">'+
'<div class="label"><span class="dot grown"></span>生发效果</div>'+
'<div class="thumb"><img src="'+grownSrc+'" alt="生发"></div></div>';
} else {
grownSlot = '<div class="img-slot">'+
'<div class="label"><span class="dot grown"></span>生发效果</div>'+
'<div class="na">⚠ 未返回<br><span style="font-size:10px;color:#9ca3af">生发失败(female=换发型/重绘,male=ComfyUI</span></div></div>';
}
html += '<div class="scheme-card">'+
'<div class="scheme-header"><span class="badge">#'+r.order+'</span> '+lb+
'<span class="tag">hairline_type: '+r.hairline_type+'</span></div>'+
'<div class="scheme-imgs">'+origSlot+overlaySlot+grownSlot+'</div>'+
'</div>';
});
$('schemesContainer').innerHTML = html;
}
function clearResults() {
_origUrl='';
$('resultsArea').classList.add('hidden'); $('statusBar').className='status hidden';
$('imageFile').value=''; $('jsonContent').textContent=''; $('schemesContainer').innerHTML='';
}
function copyJson() {
navigator.clipboard.writeText($('jsonContent').textContent).then(() => {
const b = event.target, o = b.textContent; b.textContent = '✅ 已复制'; setTimeout(() => b.textContent = o, 1500);
});
}
const HAIR_STYLES = {
female: [
{ value:'1', label:'ellipse 椭圆形' },
{ value:'2', label:'flower 花瓣形' },
{ value:'3', label:'heart 心形' },
{ value:'4', label:'straight 直线形' },
{ value:'5', label:'wave 波浪形' },
],
male: [
{ value:'1', label:'ellipse 椭圆形' },
{ value:'2', label:'inverse_arc 倒弧形' },
{ value:'3', label:'m M形' },
{ value:'4', label:'straight 直线形' },
]
};
function onGenderChange() {
const g = $('gender').value;
const div = $('hairStyleGroup');
div.innerHTML = '';
HAIR_STYLES[g].forEach(s => {
const cb = document.createElement('input');
cb.type = 'checkbox'; cb.value = s.value; cb.id = 'hs_' + s.value;
const lb = document.createElement('label');
lb.htmlFor = cb.id; lb.appendChild(cb);
lb.appendChild(document.createTextNode(' ' + s.label));
div.appendChild(lb);
});
// 重绘分辨率选项仅 female 生效:male 时隐藏
$('redrawSideGroup').style.display = (g === 'female') ? '' : 'none';
}
function selectAllHair(select) {
document.querySelectorAll('#hairStyleGroup input[type=checkbox]').forEach(cb => { cb.checked = select; });
}
document.addEventListener('DOMContentLoaded', () => {
onGenderChange(); // 初始填充发型列表
const dz = $('imageFile');
dz.addEventListener('dragover', e => { e.preventDefault(); dz.style.borderColor='#2563eb'; });
dz.addEventListener('dragleave', () => dz.style.borderColor='#ddd');
dz.addEventListener('drop', e => { e.preventDefault(); dz.style.borderColor='#ddd'; if(e.dataTransfer.files.length){ dz.files=e.dataTransfer.files; setStatus('已选择: '+e.dataTransfer.files[0].name, 'info'); } });
dz.addEventListener('change', () => { if(dz.files.length) setStatus('已选择: '+dz.files[0].name, 'info'); });
});
</script>
</body>
</html>