feat: 新增42号人脸特征与72号B端关键点接口

This commit is contained in:
xsl
2026-11-07 20:35:55 +08:00
parent ccb9c142c4
commit c9a47a76c9
16 changed files with 983 additions and 25 deletions
+64
View File
@@ -0,0 +1,64 @@
"""Run 20 local source-image cases against the ratio test server and build a report."""
import base64
import numpy as np
import csv, hashlib, html, json, math
from pathlib import Path
import cv2
import requests
ROOT=Path(__file__).resolve().parents[1]
OUT=ROOT/'static/face_ratio_cases'
OUT.mkdir(exist_ok=True)
files=sorted((ROOT/'image/girl_img').glob('*.jpg'),key=lambda p:int(p.stem.replace('girl','')))+[ROOT/'tests/fixtures/frontal.jpg',ROOT/'tests/fixtures/hard_longhair.jpg',ROOT/'image/hair_test.jpg']
files += sorted((ROOT/'tests/output/hairline_height_compare/inputs').glob('case_*.png'), key=lambda p:int(p.stem.split('_')[-1]))
keys=['hair_line_right_right','hair_line_left_left','face_edge_234','face_edge_454']
rows=[];seen=set()
for path in files:
image=cv2.imread(str(path))
if image is None:continue
digest=hashlib.sha256(image.tobytes()).hexdigest()
if digest in seen:continue
seen.add(digest)
idx=len(rows)+1
row={'case':idx,'source':str(path.relative_to(ROOT)),'ft_px':None,'zy_px':None,'ft_zy':None,'zy_ft':None,'zygoma_expansion':None,'temple_hollowing':None,'error':None}
try:
with path.open('rb') as f:r=requests.post('http://127.0.0.1:8190/detect',files={'image_file':(path.name,f,'image/jpeg')},timeout=180)
data=r.json();(OUT/f'{idx:02d}_response.json').write_text(json.dumps(data,ensure_ascii=False,indent=2))
if not r.ok:raise ValueError(data.get('detail',str(r.status_code)))
image=cv2.imdecode(np.frombuffer(base64.b64decode(data['input_image_base64'].split(',')[1]),np.uint8),cv2.IMREAD_COLOR)
row['framing']=data['framing']
points={k:data['points'].get(k) for k in keys};row['points']=points
for i in (0,2):
a,b=[points[keys[j]] for j in (i,i+1)]
if a and b:
length=math.hypot(a['x']-b['x'],a['y']-b['y']);row['ft_px' if i==0 else 'zy_px']=length
color=(140,50,240) if i==0 else (255,150,20)
cv2.line(image,(round(a['x']),round(a['y'])),(round(b['x']),round(b['y'])),color,3)
for j,p in ((i,a),(i+1,b)):
xy=(round(p['x']),round(p['y']));cv2.circle(image,xy,5,color,-1)
label=['HL RR','HL LL','234','454'][j]
cv2.putText(image,label,(max(0,xy[0]-30),max(18,xy[1]-12)),cv2.FONT_HERSHEY_SIMPLEX,.6,(0,0,0),4)
cv2.putText(image,label,(max(0,xy[0]-30),max(18,xy[1]-12)),cv2.FONT_HERSHEY_SIMPLEX,.6,(255,255,255),1)
if not row['ft_px'] or not row['zy_px']:raise ValueError('测量点缺失或距离为零')
row['ft_zy']=row['ft_px']/row['zy_px'];row['zy_ft']=row['zy_px']/row['ft_px']
matched = row['ft_zy'] < 0.8
row['zygoma_expansion'] = row['temple_hollowing'] = matched
except Exception as e:row['error']=str(e)
cv2.imwrite(str(OUT/f'{idx:02d}.jpg'),image)
rows.append(row);print(json.dumps(row,ensure_ascii=False),flush=True)
(OUT/'results.json').write_text(json.dumps(rows,ensure_ascii=False,indent=2))
with (OUT/'results.csv').open('w',encoding='utf-8-sig',newline='') as f:
writer=csv.DictWriter(f,fieldnames=[k for k in rows[0] if k!='points'],extrasaction='ignore');writer.writeheader();writer.writerows(rows)
def fmt(v):return '—' if v is None else f'{v:.4f}'
def status(v):return '无法判断' if v is None else ('命中' if v else '未命中')
valid=sum(r['error'] is None for r in rows);hit=sum(r['zygoma_expansion'] is True for r in rows)
head=f'''<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{len(rows)}例颞颧比测试结果</title><style>body{{font:15px system-ui;background:#f3f5f9;color:#203047;margin:0;padding:26px}}main{{max-width:1400px;margin:auto}}.grid{{display:grid;grid-template-columns:repeat(auto-fit,minmax(290px,1fr));gap:18px}}article{{background:white;padding:16px;border-radius:12px}}img{{width:100%;height:340px;object-fit:contain;background:#eef1f5}}td{{padding:7px 2px}}p{{line-height:1.7}}.error{{color:#ad3426}}</style><main><h1>{len(rows)}例颞颧比/颧颞比测试结果</h1><p>所有样本先使用接口52共用函数和配置扶正裁切,再测量。项目本地原始样本,按文件编号选择并剔除完全相同的图像;非人工标注验证集。共{len(rows)}例,有效{valid}例,无法计算{len(rows)-valid}例。颧弓外扩命中{hit}例,太阳穴凹陷命中{sum(r['temple_hollowing'] is True for r in rows)}例。</p><p>Ft:HL RR—HL LL(粉色,两端平均高度水平线);Zy:234—454(蓝色)。Ft ÷ Zy 小于 0.8 时,同时判定为颧弓外扩和太阳穴凹陷。缺失点不计入未命中。点击图片查看大图。</p><p><a href="results.csv">下载CSV</a> · <a href="results.json">完整JSON</a> · <a href="http://localhost:8190/">上传新照片测试</a></p><section style="position:sticky;top:0;background:#f3f5f9;padding:14px 0;z-index:2"><label>按结果筛选 <select id="filter" style="padding:9px;font:inherit"><option value="all">全部</option><option value="zygoma">颧弓外扩命中</option><option value="temple">太阳穴凹陷命中</option><option value="neither">两项均未命中</option><option value="error">无法计算</option></select></label> <label>搜索案例 <input id="search" placeholder="编号或文件名" style="padding:9px;font:inherit"></label> <span id="count"></span></section><div class="grid">'''
cards=[]
for r in rows:
i=r['case'];cards.append(f'''<article data-zygoma="{str(r['zygoma_expansion'] is True).lower()}" data-temple="{str(r['temple_hollowing'] is True).lower()}" data-error="{str(r['error'] is not None).lower()}" data-search="{i:02d} {html.escape(r['source'],quote=True)}"><h2>案例 {i:02d}</h2><p>{html.escape(r['source'])}</p><a href="{i:02d}.jpg" target="_blank"><img loading="lazy" src="{i:02d}.jpg" alt="案例{i}测量标注"></a><p>Ft {fmt(r['ft_px'])} px · Zy {fmt(r['zy_px'])} px</p><table><tr><td>颞颧比 Ft/Zy</td><td>{fmt(r['ft_zy'])}</td><td><0.8</td></tr><tr><td>颧弓外扩</td><td>{status(r['zygoma_expansion'])}</td></tr><tr><td>颧颞比 Zy/Ft</td><td>{fmt(r['zy_ft'])}</td><td>参考值</td></tr><tr><td>太阳穴凹陷</td><td>{status(r['temple_hollowing'])}</td></tr></table><p class="error">{html.escape(r['error'] or '')}</p><a href="{i:02d}_response.json">检测原始数据</a></article>''')
(OUT/'index.html').write_text(head+''.join(cards)+'''</div></main><script>
const filter=document.getElementById('filter'), search=document.getElementById('search'), cards=[...document.querySelectorAll('article')];
function apply(){let n=0;for(const c of cards){const d=c.dataset,f=filter.value;const match=f==='all'||f==='zygoma'&&d.zygoma==='true'||f==='temple'&&d.temple==='true'||f==='error'&&d.error==='true'||f==='neither'&&d.error==='false'&&d.zygoma==='false'&&d.temple==='false';const show=match&&d.search.toLowerCase().includes(search.value.trim().toLowerCase());c.hidden=!show;if(show)n++}document.getElementById('count').textContent=`显示 ${n} / ${cards.length} 例`}
filter.onchange=apply;search.oninput=apply;apply();
</script>''')
print(f'DONE total={len(rows)} valid={valid} hit={hit}',flush=True)
+44
View File
@@ -0,0 +1,44 @@
"""Local-only ratio experiment: python -m uvicorn scripts.face_ratios_test_server:app --port 8190."""
import threading
import base64
from pathlib import Path
import cv2
from fastapi import FastAPI, UploadFile, File, HTTPException
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from face_analysis.face_ratios import detect_framed_ratio_points
from face_analysis.special_points import NoFaceError, NonFrontalError
static_dir = Path(__file__).resolve().parents[1] / "static"
cases_dir = static_dir / "face_ratio_cases"
cases_dir.mkdir(exist_ok=True)
app = FastAPI()
app.mount("/cases", StaticFiles(directory=cases_dir, html=True), name="cases")
lock = threading.Lock()
@app.get("/")
def index():
return FileResponse(static_dir / "test_face_ratios.html")
@app.post("/detect")
def detect(image_file: UploadFile = File(...)):
raw = image_file.file.read(20 * 1024 * 1024 + 1)
if len(raw) > 20 * 1024 * 1024:
raise HTTPException(413, "图片不能超过20MB")
from hairline.framing import decode_portrait
image = decode_portrait(raw)
if image is None:
raise HTTPException(400, "无法读取图片")
try:
with lock:
image, data = detect_framed_ratio_points(image)
data["input_image_base64"] = (
"data:image/png;base64," + base64.b64encode(cv2.imencode(".png", image)[1]).decode()
)
return data
except (NoFaceError, NonFrontalError, ValueError) as exc:
raise HTTPException(422, str(exc)) from exc