调整 baseline 关键点 + 新增极简测试页
- head_mask.py: BASELINE_IDX 改为 [162,71,68,104,69,108,151,337,299,333,298,301,389], 左端点 21→162、右端点 251→389,新增 71/301 两点;同步更新注释 - static/test_simple.html: 极简测试页,仅需选图片+发型,原图与结果左右并排对比 - .gitignore: 忽略 report_hairline_v2.zip
This commit is contained in:
+2
-1
@@ -46,5 +46,6 @@ _grow*_resp.json
|
|||||||
# 测试素材图(体积大,不入 git)
|
# 测试素材图(体积大,不入 git)
|
||||||
image/test/
|
image/test/
|
||||||
|
|
||||||
# 批量报告输出(200张生成图+原图,体积大,不入 git)
|
# 批量报告输出(生成图+原图,体积大,不入 git)
|
||||||
static/report_hairline_v2/
|
static/report_hairline_v2/
|
||||||
|
static/report_hairline_v2.zip
|
||||||
|
|||||||
+13
-12
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
流程(详见需求讨论):
|
流程(详见需求讨论):
|
||||||
1. MediaPipe 关键点检测。
|
1. MediaPipe 关键点检测。
|
||||||
2. 底部分割线 = 关键点 [21,68,104,69,108,151,337,299,333,298,251] 的连线(左端21→中心151→右端251),
|
2. 底部分割线 = 关键点 [162,71,68,104,69,108,151,337,299,333,298,301,389] 的连线(左端162→中心151→右端389),
|
||||||
再把左端点 21 水平延伸到图片最左边、右端点 251 水平延伸到图片最右边。
|
再把左端点 162 水平延伸到图片最左边、右端点 389 水平延伸到图片最右边。
|
||||||
3. 上半区 = 分割线以上区域(多边形填充:左边缘→弧线→右边缘→上边缘闭合)。
|
3. 上半区 = 分割线以上区域(多边形填充:左边缘→弧线→右边缘→上边缘闭合)。
|
||||||
4. 头发分割:BiSeNet 与 SegFormer 各出一张 hair_mask(两套供对比)。
|
4. 头发分割:BiSeNet 与 SegFormer 各出一张 hair_mask(两套供对比)。
|
||||||
5. 闭合区域(含额头):每列从最顶端头发像素向下填充到分割线,把头发与画线之间的额头皮肤
|
5. 闭合区域(含额头):每列从最顶端头发像素向下填充到分割线,把头发与画线之间的额头皮肤
|
||||||
@@ -22,8 +22,9 @@ import numpy as np
|
|||||||
from face_analysis.detector import detector
|
from face_analysis.detector import detector
|
||||||
from face_analysis.calibration import estimate_scale_factor, normalized_to_pixel
|
from face_analysis.calibration import estimate_scale_factor, normalized_to_pixel
|
||||||
|
|
||||||
# 底部额头弧线关键点(图像上从左到右:左端 21 → 中心 151 → 右端 251)
|
# 底部分割线关键点(图像上从左到右:左端 162 → 中心 151 → 右端 389)
|
||||||
BASELINE_IDX = [21, 68, 104, 69, 108, 151, 337, 299, 333, 298, 251]
|
# 162/389 为左右最外侧端点(向图片左右边缘水平延长);中间含 71/301 等点构成弧线
|
||||||
|
BASELINE_IDX = [162, 71, 68, 104, 69, 108, 151, 337, 299, 333, 298, 301, 389]
|
||||||
CENTER_IDX = 151 # 内缩方向的目标点(额头中心)
|
CENTER_IDX = 151 # 内缩方向的目标点(额头中心)
|
||||||
ERODE_CM = 1.2 # 外缘内缩距离(厘米,默认;可由入参覆盖)
|
ERODE_CM = 1.2 # 外缘内缩距离(厘米,默认;可由入参覆盖)
|
||||||
SEGFORMER_HAIR = 13 # jonathandinu/face-parsing 中 hair 类索引
|
SEGFORMER_HAIR = 13 # jonathandinu/face-parsing 中 hair 类索引
|
||||||
@@ -50,12 +51,12 @@ def _baseline_points(landmarks, w, h):
|
|||||||
def _upper_region_mask(baseline_pts, w, h):
|
def _upper_region_mask(baseline_pts, w, h):
|
||||||
"""分割线以上区域(bool,H×W)。
|
"""分割线以上区域(bool,H×W)。
|
||||||
|
|
||||||
多边形顶点:左上角 →(0, y54)→ 弧线各点 →(w-1, y284)→ 右上角,闭合后填充。
|
多边形顶点:左上角 →(0, y左端)→ 弧线各点 →(w-1, y右端)→ 右上角,闭合后填充。
|
||||||
其中 54→左边缘、284→右边缘为两段水平延长线。
|
左端/右端为两段水平延长线(向图片左右边缘延伸)。
|
||||||
"""
|
"""
|
||||||
x54, y54 = baseline_pts[0]
|
x0, y0 = baseline_pts[0]
|
||||||
x284, y284 = baseline_pts[-1]
|
x1, y1 = baseline_pts[-1]
|
||||||
poly = [(0, 0), (0, y54)] + baseline_pts + [(w - 1, y284), (w - 1, 0)]
|
poly = [(0, 0), (0, y0)] + baseline_pts + [(w - 1, y1), (w - 1, 0)]
|
||||||
mask = np.zeros((h, w), np.uint8)
|
mask = np.zeros((h, w), np.uint8)
|
||||||
cv2.fillPoly(mask, [np.array(poly, np.int32)], 1)
|
cv2.fillPoly(mask, [np.array(poly, np.int32)], 1)
|
||||||
return mask.astype(bool)
|
return mask.astype(bool)
|
||||||
@@ -108,9 +109,9 @@ def _overlay(image, mask_bool, color, alpha=0.45):
|
|||||||
def _draw_baseline(image, baseline_pts, w):
|
def _draw_baseline(image, baseline_pts, w):
|
||||||
"""画分割线(含左右水平延长线)+ 关键点,中心点 151 标红。"""
|
"""画分割线(含左右水平延长线)+ 关键点,中心点 151 标红。"""
|
||||||
out = image.copy()
|
out = image.copy()
|
||||||
y54 = baseline_pts[0][1]
|
y0 = baseline_pts[0][1]
|
||||||
y284 = baseline_pts[-1][1]
|
y1 = baseline_pts[-1][1]
|
||||||
chain = [(0, y54)] + baseline_pts + [(w - 1, y284)]
|
chain = [(0, y0)] + baseline_pts + [(w - 1, y1)]
|
||||||
for a, b in zip(chain[:-1], chain[1:]):
|
for a, b in zip(chain[:-1], chain[1:]):
|
||||||
cv2.line(out, a, b, (0, 255, 255), 2, cv2.LINE_AA)
|
cv2.line(out, a, b, (0, 255, 255), 2, cv2.LINE_AA)
|
||||||
for idx, p in zip(BASELINE_IDX, baseline_pts):
|
for idx, p in zip(BASELINE_IDX, baseline_pts):
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
<!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", sans-serif; background: #f5f5f5; color: #333; min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px; }
|
||||||
|
.wrap { max-width: 720px; width: 100%; }
|
||||||
|
h1 { font-size: 20px; margin-bottom: 16px; text-align: center; }
|
||||||
|
.card { background: #fff; border-radius: 12px; padding: 24px; box-shadow: 0 1px 4px rgba(0,0,0,.08); margin-bottom: 20px; }
|
||||||
|
.row { display: flex; gap: 12px; align-items: flex-end; flex-wrap: wrap; }
|
||||||
|
.field { flex: 1; min-width: 180px; display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
label { font-size: 13px; font-weight: 600; color: #555; }
|
||||||
|
input[type=file] { padding: 8px; border: 2px dashed #ddd; border-radius: 8px; font-size: 14px; cursor: pointer; }
|
||||||
|
select { padding: 8px 10px; border: 1px solid #ddd; border-radius: 8px; font-size: 14px; background: #fff; }
|
||||||
|
.btn { padding: 10px 28px; border: none; border-radius: 8px; font-size: 15px; cursor: pointer; font-weight: 600; background: #2563eb; color: #fff; white-space: nowrap; }
|
||||||
|
.btn:disabled { background: #93c5fd; cursor: not-allowed; }
|
||||||
|
.status { padding: 10px 14px; 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; }
|
||||||
|
.result { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||||
|
.result .col { text-align: center; }
|
||||||
|
.result .col .cap { font-size: 13px; color: #888; margin-bottom: 8px; font-weight: 600; }
|
||||||
|
.result img { width: 100%; border-radius: 8px; box-shadow: 0 1px 6px rgba(0,0,0,.12); cursor: zoom-in; display: block; }
|
||||||
|
.result .empty { color: #bbb; padding: 60px 0; font-size: 14px; grid-column: 1 / -1; text-align: center; }
|
||||||
|
.hint { font-size: 12px; color: #999; margin-top: 8px; text-align: center; }
|
||||||
|
.lightbox { position: fixed; inset: 0; background: rgba(0,0,0,.88); display: none; align-items: center; justify-content: center; z-index: 50; cursor: zoom-out; padding: 24px; }
|
||||||
|
.lightbox img { max-width: 100%; max-height: 100%; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<h1>发际线生发</h1>
|
||||||
|
<div class="card">
|
||||||
|
<div class="row">
|
||||||
|
<div class="field">
|
||||||
|
<label>选择图片</label>
|
||||||
|
<input type="file" id="imageFile" accept="image/*">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>发型</label>
|
||||||
|
<select id="hairlineId">
|
||||||
|
<option value="chang_zhixian">直线</option>
|
||||||
|
<option value="chang_tuoyuan">椭圆</option>
|
||||||
|
<option value="chang_bolang">波浪</option>
|
||||||
|
<option value="chang_xinxing">心形</option>
|
||||||
|
<option value="chang_huaban">花瓣</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button class="btn" id="submitBtn" onclick="submit()">生成</button>
|
||||||
|
</div>
|
||||||
|
<div class="hint">其余参数走默认值(pushed 遮罩 + multiband 融合)</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status hidden" id="statusBar"></div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="result" id="resultArea">
|
||||||
|
<div class="empty">上传图片、选发型、点生成</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lightbox" id="lightbox" onclick="this.style.display='none'"><img id="lightboxImg"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const API = window.location.origin + '/api/v1/hairline/grow_v2';
|
||||||
|
const TOKEN = 'dev-shared-secret-2026';
|
||||||
|
|
||||||
|
function $(id) { return document.getElementById(id); }
|
||||||
|
function setStatus(text, type) { const b = $('statusBar'); b.textContent = text; b.className = 'status ' + type; }
|
||||||
|
function zoom(src) { $('lightboxImg').src = src; $('lightbox').style.display = 'flex'; }
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
const file = $('imageFile').files[0];
|
||||||
|
if (!file) { setStatus('请先选择图片', 'error'); return; }
|
||||||
|
const btn = $('submitBtn');
|
||||||
|
btn.disabled = true; btn.textContent = '生成中...';
|
||||||
|
setStatus('正在生成(约 5~15 秒)...', 'info');
|
||||||
|
// 先读原图用于对比
|
||||||
|
const origSrc = await new Promise(r => { const fr = new FileReader(); fr.onload = () => r(fr.result); fr.readAsDataURL(file); });
|
||||||
|
$('resultArea').innerHTML =
|
||||||
|
'<div class="col"><div class="cap">原图</div><img src="' + origSrc + '"></div>'
|
||||||
|
+ '<div class="col"><div class="cap">⏳ 生成中...</div><div class="empty" style="padding:40px 0">处理中</div></div>';
|
||||||
|
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('image_file', file);
|
||||||
|
form.append('hairline_id', $('hairlineId').value);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(API, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-Internal-Token': TOKEN },
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
const json = await resp.json();
|
||||||
|
if (json.code === 0 && json.data && json.data.final_base64) {
|
||||||
|
const src = json.data.final_base64;
|
||||||
|
setStatus('✅ 生成成功', 'success');
|
||||||
|
$('resultArea').innerHTML =
|
||||||
|
'<div class="col"><div class="cap">原图</div><img src="' + origSrc + '" onclick="zoom(this.src)"></div>'
|
||||||
|
+ '<div class="col"><div class="cap">生成结果</div><img src="' + src + '" onclick="zoom(this.src)"></div>';
|
||||||
|
} else {
|
||||||
|
setStatus('❌ 失败:' + (json.message || '未知错误'), 'error');
|
||||||
|
$('resultArea').innerHTML = '<div class="empty">生成失败:' + (json.message||'') + '</div>';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setStatus('❌ 网络错误:' + err.message, 'error');
|
||||||
|
$('resultArea').innerHTML = '<div class="empty">请求失败</div>';
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false; btn.textContent = '生成';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$('imageFile').addEventListener('change', function() {
|
||||||
|
if (this.files.length) setStatus('已选择:' + this.files[0].name, 'info');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user