feat: 新增接口22生发控制与测试页

This commit is contained in:
xsl
2026-11-09 21:19:39 +08:00
parent c800822d54
commit b84a506b9b
21 changed files with 770 additions and 175 deletions
+451
View File
@@ -0,0 +1,451 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>接口 22 · 生发测试</title>
<style>
* { box-sizing: border-box; }
body { margin: 0; background: #f3f5f8; color: #172334; font: 15px/1.5 system-ui, sans-serif; }
main { max-width: 1100px; margin: 32px auto; padding: 0 16px; }
h1 { margin: 0 0 4px; font-size: 28px; }
h2 { margin: 0 0 12px; font-size: 18px; }
.muted, figcaption { color: #526075; }
.card { background: white; border: 1px solid #dce3eb; border-radius: 12px; padding: 18px; margin: 16px 0; }
.fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 14px; }
.field { display: grid; gap: 5px; align-content: start; }
input, select, button { font: inherit; }
.field input, select { min-width: 0; width: 100%; padding: 6px 8px; }
fieldset { margin: 16px 0; border: 1px solid #dce3eb; border-radius: 8px; }
#sourceFields { margin-top: 0; padding: 0; border: 0; min-width: 0; }
.choices { display: flex; flex-wrap: wrap; gap: 8px 18px; }
.choices label { white-space: nowrap; }
button { background: #1757ab; color: white; border: 0; border-radius: 8px; padding: 8px 16px; cursor: pointer; }
button:disabled { opacity: .6; cursor: wait; }
.status { margin-bottom: 0; }
.status[data-error="true"] { color: #a02020; }
.images { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); gap: 12px; }
.images figure { max-width: 380px; }
.result-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 122px), 1fr)); gap: 10px; }
.result-tile { min-width: 0; overflow: hidden; background: white; border: 1px solid #dce3eb; border-radius: 8px; }
.result-tile img { display: block; width: 100%; height: auto; }
.result-tile figcaption { display: grid; gap: 2px; margin: 0; padding: 6px 8px; font-size: 12px; }
.result-tile strong { color: #172334; }
.preview-editor { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: 12px; margin: 16px 0; }
.preview-editor:has(#previews > figure:only-child) { grid-template-columns: minmax(0, 380px) auto; justify-content: start; }
.height-controls { display: grid; gap: 6px; padding-top: 27px; }
.height-scale { display: flex; gap: 6px; height: clamp(170px, 42vw, 300px); }
#height { writing-mode: vertical-lr; direction: rtl; width: 24px; height: 100%; margin: 0; }
.height-presets { display: flex; flex-direction: column; justify-content: space-between; }
.height-presets label { display: flex; align-items: center; gap: 3px; cursor: pointer; white-space: nowrap; }
figure { margin: 0; min-width: 0; }
figcaption { margin-bottom: 6px; }
.stack { position: relative; overflow: hidden; border-radius: 6px; }
.stack img, .stack canvas { width: 100%; height: auto; display: block; border-radius: 6px; }
.preview-layer, .preview-clip, .stack .overlay, .crop-border { position: absolute; inset: 0; width: 100%; height: 100%; }
.preview-layer { transform: translateY(calc(-1 * var(--height-adjustment) * var(--max-shift))); }
.crop-border polygon { fill: none; stroke: #e33399; stroke-width: 2; stroke-dasharray: 6 4; vector-effect: non-scaling-stroke; }
pre { white-space: pre-wrap; overflow-wrap: anywhere; }
@media (max-width: 560px) { .images { grid-template-columns: 1fr; } }
</style>
<script src="/static/img_downscale.js?v=2" defer></script>
<script src="/static/hairline_reference_box.js?v=2" defer></script>
</head>
<body>
<main>
<h1>接口 22 生发测试</h1>
<div class="muted"><code>POST /api/v1/hair/grow-22</code></div>
<form id="generateRequest" class="card">
<fieldset id="sourceFields" aria-label="照片与性别">
<div class="fields">
<label class="field">照片(JPG / PNG)
<input type="file" name="image_file" accept="image/jpeg,image/png,.jpg,.jpeg,.png" required>
</label>
<label class="field">性别
<select name="gender"><option value="female">女性</option><option value="male">男性</option></select>
</label>
</div>
</fieldset>
<fieldset id="styleFields">
<legend>发际线形状(可多选)</legend>
<div id="styles" class="choices"></div>
<p id="generationPreviewStatus" class="status" role="status"></p>
</fieldset>
<p id="firstRunHint" class="muted">首次按默认高度 0 生图,出图后可预览和调整发际线高度。</p>
<div id="heightEditor" hidden>
<p class="muted">勾选右侧档位后按所选高度生图。未勾选时,使用滑块当前高度。</p>
<div class="preview-editor">
<div id="previews" class="images" aria-label="52 号裁切发际线预览"></div>
<div class="height-controls">
<label for="height">高度 <output id="heightValue" for="height">0</output></label>
<div class="height-scale">
<input type="range" id="height" name="hairline_height" min="-1" max="1" step="0.1" value="0">
<div class="height-presets" role="group" aria-label="预设高度(可多选)">
<label><input type="checkbox" name="height_preset" value="1"> +1</label>
<label><input type="checkbox" name="height_preset" value="0.5"> +0.5</label>
<label><input type="checkbox" name="height_preset" value="0"> 0</label>
<label><input type="checkbox" name="height_preset" value="-0.5"> −0.5</label>
<label><input type="checkbox" name="height_preset" value="-1"> −1</label>
</div>
</div>
</div>
</div>
</div>
<fieldset>
<legend>生发密度(至少选择一档)</legend>
<div class="choices">
<label><input type="checkbox" name="hair_density" value="sparse"> 稀疏</label>
<label><input type="checkbox" name="hair_density" value="normal" checked> 正常</label>
<label><input type="checkbox" name="hair_density" value="dense"> 浓密</label>
</div>
</fieldset>
<button id="submit" type="submit">生成生发效果</button>
<p id="status" class="status" role="status"></p>
</form>
<section id="results" class="result-grid" aria-label="生发结果"></section>
<details class="card" id="response" hidden>
<summary>响应 JSON(省略图片 Base64)</summary>
<pre id="json"></pre>
</details>
</main>
<script>
const generateForm = document.getElementById('generateRequest');
const sourceFields = document.getElementById('sourceFields');
const firstRunHint = document.getElementById('firstRunHint');
const heightEditor = document.getElementById('heightEditor');
const previews = document.getElementById('previews');
const height = document.getElementById('height');
const heightPresets = generateForm.querySelectorAll('[name="height_preset"]');
const styles = document.getElementById('styles');
const styleFields = document.getElementById('styleFields');
const generationPreviewStatus = document.getElementById('generationPreviewStatus');
const statusBar = document.getElementById('status');
const results = document.getElementById('results');
const response = document.getElementById('response');
const submit = document.getElementById('submit');
const densityLabels = { sparse: '稀疏', normal: '正常', dense: '浓密' };
const styleNames = {
female: ['椭圆', '花瓣', '心形', '直线', '波浪', '大花瓣', '古典花瓣'],
male: ['椭圆', '倒弧', 'M 形', '直线', '心形', '柔和花瓣', '波浪'],
};
let prepared;
let previewRevision = 0;
function renderStyleChoices(container, gender, selected) {
container.replaceChildren(...styleNames[gender].map((name, index) => {
const label = document.createElement('label');
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.name = 'hair_style';
checkbox.value = index + 1;
checkbox.checked = selected.includes(checkbox.value);
label.append(checkbox, ' ' + (index + 1) + '. ' + name);
return label;
}));
}
function imageSource(url, base64, mime) {
if (url) return url;
if (!base64) return '';
return base64.startsWith('data:') ? base64 : 'data:' + mime + ';base64,' + base64;
}
function imagePanel(title, src, preview) {
const figure = document.createElement('figure');
const caption = document.createElement('figcaption');
caption.textContent = title;
figure.append(caption);
if (!src) {
figure.append(preview?.error || '未返回图片');
return figure;
}
const stack = document.createElement('div');
stack.className = 'stack';
const image = new Image();
image.src = src;
image.alt = title;
stack.append(image);
if (preview) {
const movingLayer = document.createElement('div');
movingLayer.className = 'preview-layer';
movingLayer.style.setProperty('--max-shift', preview.maxShift + '%');
const clip = document.createElement('div');
clip.className = 'preview-clip';
const polygonPoints = preview.corners.map(([x, y]) =>
(100 * x / preview.width) + '% ' + (100 * y / preview.height) + '%');
clip.style.clipPath = 'polygon(' + polygonPoints.join(', ') + ')';
const layer = new Image();
layer.src = preview.overlay;
layer.alt = '发际线';
layer.className = 'overlay';
clip.append(layer);
movingLayer.append(clip);
const ns = 'http://www.w3.org/2000/svg';
const border = document.createElementNS(ns, 'svg');
border.classList.add('crop-border');
border.setAttribute('viewBox', '0 0 ' + preview.width + ' ' + preview.height);
border.setAttribute('aria-label', '发际线裁切边界');
const polygon = document.createElementNS(ns, 'polygon');
polygon.setAttribute('points', preview.corners.map(point => point.join(',')).join(' '));
border.append(polygon);
movingLayer.append(border);
stack.append(movingLayer);
}
figure.append(stack);
return figure;
}
async function loadCroppedPreviews(data, selected) {
const formData = new FormData();
for (const [key, value] of data) formData.set(key, value);
formData.set('hair_style', selected.join(','));
formData.set('normalize_image', 'true');
formData.set('generate_grow_image', 'false');
const reply = await fetch('/api/v1/hairline/generate-52', { method: 'POST', body: formData });
const body = await reply.json();
if (!reply.ok || body.code !== 0) {
throw new Error(body.message || 'HTTP ' + reply.status);
}
const preview = body.data;
const base = imageSource(preview.input_image_url, preview.input_image_base64, 'image/jpeg');
const { width, height } = preview;
const { corners, landmarks } = window.hairlineReferenceBox(preview.guide_landmarks);
// Convert the interface 22 scale (10% of brow-to-chin distance) to a percentage of the image height.
const maxShift = 100 * 0.10 * Math.abs(landmarks[152][1] - landmarks[9][1]) / height;
const items = preview.hairline_images.map(item => {
const overlay = imageSource(item.image_middle_url, item.image_middle_base64, 'image/png');
return { order: item.order, ...((base && overlay)
? { base, corners, width, height, maxShift, overlay }
: { error: '接口 52 未返回完整预览图片' }) };
});
return { items, framing: preview.framing };
}
function makePreviewPanels(items, gender) {
const names = styleNames[gender];
return new Map(items.filter(item => item.base).map(item => [
String(item.order), imagePanel('#' + item.order + ' · ' + names[item.order - 1], item.base, item),
]));
}
async function replacePreviewBases(images, panels, framing, fallback = false) {
const byOrder = new Map(images.map(item => [String(item.order), item]));
const targets = [...panels].flatMap(([order, panel]) => {
const image = byOrder.get(order) || (fallback ? images[0] : undefined);
return image ? [[image, panel]] : [];
});
if (!targets.length) return 0;
const { source_to_processed: matrix, source_size: source, output_size: output } = framing || {};
if (!matrix || !source || !output) return targets.length;
const [[a, c, e], [b, d, f]] = matrix;
const updated = await Promise.all(targets.map(async ([item, panel]) => {
const base = panel.querySelector('.stack > img');
if (!base) return false;
try {
const generated = new Image();
generated.src = imageSource(item.grown_image_url, item.grown_image_base64, 'image/jpeg');
await generated.decode();
const canvas = document.createElement('canvas');
canvas.width = output.width;
canvas.height = output.height;
canvas.setAttribute('role', 'img');
canvas.setAttribute('aria-label', base.alt + ' · 默认高度生发底图');
const ctx = canvas.getContext('2d', { alpha: false });
ctx.setTransform(a, b, c, d, e, f);
ctx.drawImage(generated, 0, 0, source.width, source.height);
base.replaceWith(canvas);
return true;
} catch (error) {
return false;
}
}));
return updated.filter(ok => !ok).length;
}
function renderResult(item, submittedHeight, gender) {
const tile = document.createElement('figure');
tile.className = 'result-tile';
const image = new Image();
image.loading = 'lazy';
image.src = imageSource(item.grown_image_url, item.grown_image_base64, 'image/jpeg');
const name = styleNames[gender][item.order - 1] || item.hairline_type;
const style = '#' + item.order + ' ' + name + ' · ' + (gender === 'male' ? '男性' : '女性');
const details = (densityLabels[item.hair_density] || item.hair_density) + ' · 高度 ' + submittedHeight;
image.alt = style + ',' + details;
const caption = document.createElement('figcaption');
const title = document.createElement('strong');
title.textContent = style;
const parameters = document.createElement('span');
parameters.textContent = details;
caption.append(title, parameters);
tile.append(image, caption);
return tile;
}
async function requestGrowth(data) {
const reply = await fetch('/api/v1/hair/grow-22', { method: 'POST', body: data });
const body = await reply.json();
if (!reply.ok || body.code !== 0) {
throw new Error(body.message || 'HTTP ' + reply.status);
}
return body;
}
function updatePreviewHeight() {
document.getElementById('heightValue').value = height.value;
previews.style.setProperty('--height-adjustment', height.value);
}
height.addEventListener('input', updatePreviewHeight);
heightPresets.forEach(preset => preset.addEventListener('change', () => {
if (preset.checked) {
height.value = preset.value;
updatePreviewHeight();
}
}));
updatePreviewHeight();
function renderSourceStyles() {
renderStyleChoices(styles, generateForm.elements.gender.value, ['1']);
}
generateForm.elements.gender.addEventListener('change', renderSourceStyles);
renderSourceStyles();
sourceFields.addEventListener('change', () => {
prepared = undefined;
heightEditor.hidden = true;
firstRunHint.hidden = false;
previews.replaceChildren();
results.replaceChildren();
response.hidden = true;
statusBar.textContent = '';
generationPreviewStatus.textContent = '';
submit.disabled = false;
height.value = '0';
heightPresets.forEach(preset => { preset.checked = false; });
updatePreviewHeight();
});
async function refreshStylePreviews(selected) {
const current = prepared;
if (!current) return;
const revision = ++previewRevision;
const showSelected = () => previews.replaceChildren(...selected.flatMap(order =>
current.panels.has(order) ? [current.panels.get(order)] : []));
showSelected();
generationPreviewStatus.dataset.error = 'false';
generationPreviewStatus.textContent = selected.length ? '' : '请至少选择一种发际线形状。';
const missing = selected.filter(order => !current.panels.has(order));
submit.disabled = !selected.length || !!missing.length;
if (!missing.length) return;
generationPreviewStatus.textContent = '正在更新发际线预览…';
try {
const { items, framing } = await loadCroppedPreviews(current.data, missing);
const panels = makePreviewPanels(items, current.data.get('gender'));
const unmatched = await replacePreviewBases(current.defaultImages, panels, framing, true);
if (current !== prepared || revision !== previewRevision) return;
for (const [order, panel] of panels) current.panels.set(order, panel);
showSelected();
if (missing.some(order => !panels.has(order))) {
throw new Error('接口 52 未返回全部所选形状。');
}
generationPreviewStatus.textContent = unmatched ? '部分生成图无法对齐,已显示对应的裁切原照。' : '';
} catch (error) {
if (current !== prepared || revision !== previewRevision) return;
generationPreviewStatus.dataset.error = 'true';
generationPreviewStatus.textContent = '更新发际线预览失败:' + error.message;
} finally {
if (current === prepared && revision === previewRevision) submit.disabled = false;
}
}
styles.addEventListener('change', () => {
refreshStylePreviews(new FormData(generateForm).getAll('hair_style'));
});
generateForm.addEventListener('submit', async (event) => {
event.preventDefault();
if (submit.disabled) return;
const data = new FormData(generateForm);
const selectedStyles = data.getAll('hair_style');
if (!selectedStyles.length) {
statusBar.dataset.error = 'true';
statusBar.textContent = '请至少选择一种发际线形状。';
return;
}
const densities = data.getAll('hair_density');
if (!densities.length) {
statusBar.dataset.error = 'true';
statusBar.textContent = '请至少选择一档生发密度。';
return;
}
const selectedHeights = data.getAll('height_preset');
const firstRun = !prepared;
const submittedHeights = firstRun ? ['0'] : (selectedHeights.length ? selectedHeights : [data.get('hairline_height')]);
data.delete('height_preset');
data.set('hair_style', selectedStyles.join(','));
sourceFields.disabled = styleFields.disabled = submit.disabled = true;
statusBar.dataset.error = 'false';
results.replaceChildren();
response.hidden = true;
const reports = [];
const failedHeights = [];
const defaultImages = new Map();
const planned = submittedHeights.length * densities.length * selectedStyles.length;
let generated = 0;
const start = performance.now();
try {
statusBar.textContent = '正在准备照片…';
const file = firstRun
? await window.downscaleImageFile(data.get('image_file'))
: prepared.data.get('image_file');
data.set('image_file', file);
for (const [index, submittedHeight] of submittedHeights.entries()) {
statusBar.textContent = '正在生成 ' + (index + 1) + '/' + submittedHeights.length + ' 档 · 高度 ' + submittedHeight + '…';
// The slider is positive up, while the API uses positive image Y (down).
data.set('hairline_height', String(-Number(submittedHeight)));
try {
const body = await requestGrowth(data);
const images = body.data.results.filter(item => item.grown_image_url || item.grown_image_base64);
results.append(...images.map(item => renderResult(item, submittedHeight, data.get('gender'))));
generated += images.length;
if (firstRun) {
for (const item of images) {
if (!defaultImages.has(item.order) || item.hair_density === 'normal') {
const { order, grown_image_url, grown_image_base64 } = item;
defaultImages.set(order, { order, grown_image_url, grown_image_base64 });
}
}
}
reports.push(JSON.stringify({ height: Number(submittedHeight), response: body }, (key, value) =>
key.endsWith('_base64') && typeof value === 'string' ? '[图片已省略]' : value, 2));
} catch (error) {
failedHeights.push(submittedHeight);
reports.push(JSON.stringify({ height: Number(submittedHeight), error: error.message }, null, 2));
}
}
document.getElementById('json').textContent = '[\n' + reports.join(',\n') + '\n]';
response.hidden = false;
const seconds = ((performance.now() - start) / 1000).toFixed(1);
statusBar.dataset.error = String(generated !== planned);
statusBar.textContent = '生发成功 ' + generated + '/' + planned + ' 张 · ' + seconds + ' 秒'
+ (failedHeights.length ? ' · 请求失败的高度 ' + failedHeights.join('、') : '');
if (firstRun && generated) {
const source = new FormData();
source.set('image_file', file);
source.set('gender', data.get('gender'));
prepared = { data: source, panels: new Map(), defaultImages: [...defaultImages.values()] };
heightEditor.hidden = false;
firstRunHint.hidden = true;
await refreshStylePreviews(selectedStyles);
}
} catch (error) {
statusBar.dataset.error = 'true';
statusBar.textContent = '生发失败:' + error.message;
} finally {
sourceFields.disabled = styleFields.disabled = submit.disabled = false;
}
});
</script>
</body>
</html>