基于 MediaPipe 468 关键点提取几何特征,转 z 分数后与各脸型原型加权匹配。 参考分布与原型靶心取自 1093 张测试集的实测画像,不再靠人工设定绝对阈值。 方形脸占比从 29.6% 降到 13.8%,两处原因:一是参考统计量原先只由 50 张样本 估得,相对全量人群有系统性偏移,且三项偏移都在给方形脸加分;二是原型把 aspect_ratio 当作方形脸的主特征,但实测方脸组该值中位仅 +0.16,真正"宽"的 是圆脸(+1.01),等于在拿脸宽找方脸。 原型参数在「6 张基准标注图判定不变、且领先第二名 >=3 分」的约束下搜索得到。 余量约束是必要的:早前一版余量仅 0.008 分,权重写码时四舍五入就会翻转结论。 测试素材(人像照片)与报告输出体积大,一并加入 .gitignore。 Co-authored-by: Cursor <cursoragent@cursor.com>
360 lines
13 KiB
Python
360 lines
13 KiB
Python
"""
|
||
build_report.py
|
||
对 face/test_img/girl 与 face/test_img/man 下的照片批量预测脸型,
|
||
在照片上标注 face_width / face_height 等特征,并生成 HTML 报告。
|
||
|
||
用法:
|
||
./venv/bin/python face/build_report.py
|
||
输出:
|
||
static/face_shape_report.html
|
||
static/face_shape_report/images/*.jpg
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import html
|
||
import re
|
||
import shutil
|
||
import sys
|
||
from collections import Counter
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from typing import Dict, List
|
||
|
||
import cv2
|
||
|
||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||
|
||
from face.face_shape_classifier import classify_from_image # noqa: E402
|
||
|
||
ROOT = Path(__file__).resolve().parents[1]
|
||
SRC_DIRS = {
|
||
"女": ROOT / "face/test_img/girl",
|
||
"男": ROOT / "face/test_img/man",
|
||
}
|
||
OUT_DIR = ROOT / "static/face_shape_report"
|
||
IMG_DIR = OUT_DIR / "images"
|
||
OUT_HTML = ROOT / "static/face_shape_report.html"
|
||
|
||
MAX_IMAGE_SIDE = 900
|
||
JPEG_QUALITY = 90
|
||
|
||
SHAPE_ORDER = ["圆形脸", "心形脸", "菱形脸", "鹅蛋脸", "方形脸", "长形脸", "瓜子脸"]
|
||
SHAPE_COLORS = {
|
||
"圆形脸": "#e67e22",
|
||
"心形脸": "#e74c3c",
|
||
"菱形脸": "#9b59b6",
|
||
"鹅蛋脸": "#27ae60",
|
||
"方形脸": "#2980b9",
|
||
"长形脸": "#16a085",
|
||
"瓜子脸": "#c0392b",
|
||
}
|
||
FEATURE_KEYS = [
|
||
"face_width",
|
||
"face_height",
|
||
"jaw_angle",
|
||
"taper_ratio",
|
||
"forehead_ratio",
|
||
"cheekbone_ratio",
|
||
"jaw_ratio",
|
||
"chin_ratio",
|
||
"chin_sharpness",
|
||
"width_uniformity",
|
||
"face_curve_score",
|
||
]
|
||
|
||
|
||
def natural_key(path: Path):
|
||
m = re.search(r"(\d+)", path.stem)
|
||
return (0, int(m.group(1))) if m else (1, path.stem)
|
||
|
||
|
||
def analyze_all() -> tuple[List[Dict], Dict[str, Counter]]:
|
||
if IMG_DIR.exists():
|
||
shutil.rmtree(IMG_DIR)
|
||
IMG_DIR.mkdir(parents=True)
|
||
|
||
rows: List[Dict] = []
|
||
summary = {"女": Counter(), "男": Counter(), "all": Counter()}
|
||
|
||
for gender, src in SRC_DIRS.items():
|
||
prefix = "girl" if gender == "女" else "man"
|
||
paths = sorted(
|
||
[p for p in src.iterdir() if p.suffix.lower() in {".jpg", ".jpeg", ".png", ".webp"}],
|
||
key=natural_key,
|
||
)
|
||
for path in paths:
|
||
m = re.search(r"(\d+)", path.stem)
|
||
out_name = f"{prefix}_{int(m.group(1)) if m else 0:02d}.jpg"
|
||
dest = IMG_DIR / out_name
|
||
|
||
item = {
|
||
"gender": gender,
|
||
"file": path.name,
|
||
"img_src": f"face_shape_report/images/{out_name}",
|
||
"ok": False,
|
||
"predicted": None,
|
||
"display": None,
|
||
"confidence": None,
|
||
"score": None,
|
||
"top3": [],
|
||
"features": {},
|
||
"error": None,
|
||
}
|
||
try:
|
||
result = classify_from_image(path, return_details=True, return_annotated=True)
|
||
annotated = result["annotated"]
|
||
h, w = annotated.shape[:2]
|
||
if max(h, w) > MAX_IMAGE_SIDE:
|
||
scale = MAX_IMAGE_SIDE / max(h, w)
|
||
annotated = cv2.resize(
|
||
annotated, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_AREA
|
||
)
|
||
cv2.imwrite(str(dest), annotated, [int(cv2.IMWRITE_JPEG_QUALITY), JPEG_QUALITY])
|
||
|
||
item.update(
|
||
{
|
||
"ok": True,
|
||
"predicted": result["face_shape"],
|
||
"display": result["display"],
|
||
"confidence": result["confidence"],
|
||
"score": result["details"]["ranked"][0][1],
|
||
"top3": result["details"]["ranked"][:3],
|
||
"features": {k: result["features"][k] for k in FEATURE_KEYS},
|
||
}
|
||
)
|
||
summary[gender][result["face_shape"]] += 1
|
||
summary["all"][result["face_shape"]] += 1
|
||
except Exception as exc: # noqa: BLE001 - 报告需要汇总所有失败
|
||
img = cv2.imread(str(path))
|
||
if img is not None:
|
||
cv2.imwrite(str(dest), img, [int(cv2.IMWRITE_JPEG_QUALITY), JPEG_QUALITY])
|
||
item["error"] = str(exc)
|
||
summary[gender]["检测失败"] += 1
|
||
summary["all"]["检测失败"] += 1
|
||
|
||
rows.append(item)
|
||
print(f"[{gender}] {path.name} -> {item['display'] or 'ERR ' + str(item['error'])}")
|
||
|
||
return rows, summary
|
||
|
||
|
||
def count_table(counter: Counter) -> str:
|
||
if not counter:
|
||
return "<p class='muted'>无数据</p>"
|
||
total = sum(counter.values())
|
||
parts = []
|
||
for shape, n in sorted(counter.items(), key=lambda x: (-x[1], x[0])):
|
||
color = SHAPE_COLORS.get(shape, "#7f8c8d")
|
||
pct = n / total * 100
|
||
parts.append(
|
||
f"<div class='bar-row'><span class='bar-label'>{html.escape(shape)}</span>"
|
||
f"<div class='bar-track'><div class='bar-fill' style='width:{pct:.1f}%;background:{color}'></div></div>"
|
||
f"<span class='bar-num'>{n}({pct:.0f}%)</span></div>"
|
||
)
|
||
return "".join(parts)
|
||
|
||
|
||
def fmt_feat(key: str, value: float) -> str:
|
||
if key in {"face_width", "face_height"}:
|
||
return f"{value:.1f}px"
|
||
if key == "jaw_angle":
|
||
return f"{value:.1f}°"
|
||
return f"{value:.3f}"
|
||
|
||
|
||
def card(item: Dict) -> str:
|
||
if not item["ok"]:
|
||
return f"""
|
||
<article class="card error">
|
||
<a class="img-link" href="{html.escape(item['img_src'])}" target="_blank">
|
||
<img src="{html.escape(item['img_src'])}" alt="{html.escape(item['file'])}" loading="lazy"/>
|
||
</a>
|
||
<div class="body">
|
||
<h3>{html.escape(item['file'])}</h3>
|
||
<p class="badge bad">检测失败</p>
|
||
<p class="muted">{html.escape(item['error'] or '')}</p>
|
||
</div>
|
||
</article>"""
|
||
|
||
color = SHAPE_COLORS.get(item["predicted"], "#34495e")
|
||
top3 = "".join(
|
||
f"<li><span>{html.escape(name)}</span><b>{score:.1f}</b></li>" for name, score in item["top3"]
|
||
)
|
||
feat_html = "".join(
|
||
f"<tr><td>{html.escape(k)}</td><td>{html.escape(fmt_feat(k, v))}</td></tr>"
|
||
for k, v in item["features"].items()
|
||
)
|
||
return f"""
|
||
<article class="card">
|
||
<a class="img-link" href="{html.escape(item['img_src'])}" target="_blank" title="点击查看大图标注">
|
||
<img src="{html.escape(item['img_src'])}" alt="{html.escape(item['file'])}" loading="lazy"/>
|
||
</a>
|
||
<div class="body">
|
||
<div class="meta">
|
||
<h3>{html.escape(item['file'])}</h3>
|
||
<span class="gender">{html.escape(item['gender'])}</span>
|
||
</div>
|
||
<p class="badge" style="background:{color}">{html.escape(item['display'])}</p>
|
||
<p class="conf">匹配度 {item['score']:.1f} · 置信度 {item['confidence']:.3f}</p>
|
||
<h4>Top-3 得分</h4>
|
||
<ul class="scores">{top3}</ul>
|
||
<details>
|
||
<summary>标注特征数值</summary>
|
||
<table>{feat_html}</table>
|
||
</details>
|
||
</div>
|
||
</article>"""
|
||
|
||
|
||
CSS = """
|
||
:root {
|
||
--bg: #f3efe6; --ink: #1c1915; --muted: #6b645a;
|
||
--card: #fffdf8; --line: #e2d8c8; --accent: #0f6b5c;
|
||
}
|
||
* { box-sizing: border-box; }
|
||
body {
|
||
margin: 0;
|
||
font-family: "PingFang SC", "Noto Sans SC", "Segoe UI", sans-serif;
|
||
color: var(--ink);
|
||
background:
|
||
radial-gradient(1200px 600px at 10% -10%, #ffe8c8 0%, transparent 55%),
|
||
radial-gradient(900px 500px at 100% 0%, #d9f2ea 0%, transparent 50%),
|
||
var(--bg);
|
||
}
|
||
header { padding: 40px 24px 20px; max-width: 1280px; margin: 0 auto; }
|
||
header h1 { margin: 0 0 8px; font-size: clamp(1.8rem, 3vw, 2.4rem); letter-spacing: .02em; }
|
||
header p { margin: 4px 0; color: var(--muted); }
|
||
.legend-box { max-width: 1280px; margin: 0 auto 20px; padding: 0 24px; }
|
||
.legend-box .inner {
|
||
background: var(--card); border: 1px solid var(--line);
|
||
border-radius: 14px; padding: 14px 16px; font-size: .9rem; line-height: 1.55;
|
||
}
|
||
.legend-box code { background: #efe7da; padding: 1px 6px; border-radius: 4px; font-size: .84rem; }
|
||
.swatch { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 4px; vertical-align: middle; }
|
||
.stats {
|
||
display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||
gap: 16px; max-width: 1280px; margin: 0 auto 28px; padding: 0 24px;
|
||
}
|
||
.stat { background: var(--card); border: 1px solid var(--line); border-radius: 16px; padding: 16px 18px; }
|
||
.stat h2 { margin: 0 0 12px; font-size: 1rem; }
|
||
.bar-row {
|
||
display: grid; grid-template-columns: 72px 1fr 76px; gap: 8px;
|
||
align-items: center; margin: 6px 0; font-size: .86rem;
|
||
}
|
||
.bar-track { height: 8px; background: #efe7da; border-radius: 999px; overflow: hidden; }
|
||
.bar-fill { height: 100%; border-radius: 999px; }
|
||
.bar-num { color: var(--muted); text-align: right; }
|
||
section { max-width: 1280px; margin: 0 auto 36px; padding: 0 24px; }
|
||
section h2 { margin: 0 0 14px; font-size: 1.35rem; border-left: 4px solid var(--accent); padding-left: 10px; }
|
||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }
|
||
.card {
|
||
background: var(--card); border: 1px solid var(--line); border-radius: 18px;
|
||
overflow: hidden; display: flex; flex-direction: column;
|
||
box-shadow: 0 8px 24px rgba(60, 40, 10, .05);
|
||
}
|
||
.card.error { opacity: .9; }
|
||
.img-link { display: block; }
|
||
.card img { width: 100%; aspect-ratio: 3/4; object-fit: cover; background: #ddd; display: block; }
|
||
.card .body { padding: 14px 14px 16px; }
|
||
.meta { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; }
|
||
.meta h3 { margin: 0; font-size: 1rem; }
|
||
.gender { font-size: .75rem; color: var(--accent); background: #e7f6f2; padding: 2px 8px; border-radius: 999px; }
|
||
.badge {
|
||
display: inline-block; margin: 10px 0 4px; color: #fff;
|
||
padding: 6px 10px; border-radius: 999px; font-weight: 600; font-size: .92rem;
|
||
}
|
||
.badge.bad { background: #c0392b; }
|
||
.conf { margin: 0 0 10px; color: var(--muted); font-size: .85rem; }
|
||
.scores { list-style: none; padding: 0; margin: 0 0 8px; }
|
||
.scores li {
|
||
display: flex; justify-content: space-between; padding: 4px 0;
|
||
border-bottom: 1px dashed var(--line); font-size: .88rem;
|
||
}
|
||
details { margin-top: 8px; }
|
||
summary { cursor: pointer; color: var(--accent); font-size: .88rem; }
|
||
table { width: 100%; border-collapse: collapse; margin-top: 8px; font-size: .8rem; }
|
||
td { padding: 3px 0; border-bottom: 1px solid var(--line); }
|
||
td:last-child { text-align: right; font-variant-numeric: tabular-nums; }
|
||
.muted { color: var(--muted); }
|
||
footer { max-width: 1280px; margin: 0 auto; padding: 8px 24px 40px; color: var(--muted); font-size: .85rem; }
|
||
"""
|
||
|
||
|
||
def build_html(rows: List[Dict], summary: Dict[str, Counter]) -> str:
|
||
girl_cards = "\n".join(card(r) for r in rows if r["gender"] == "女")
|
||
man_cards = "\n".join(card(r) for r in rows if r["gender"] == "男")
|
||
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
n_girl = sum(1 for r in rows if r["gender"] == "女")
|
||
n_man = sum(1 for r in rows if r["gender"] == "男")
|
||
n_ok = sum(1 for r in rows if r["ok"])
|
||
n_kind = len([s for s in SHAPE_ORDER if summary["all"].get(s)])
|
||
|
||
return f"""<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8"/>
|
||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||
<title>脸型分类预测报告(特征标注)</title>
|
||
<style>{CSS}</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<h1>脸型分类预测报告</h1>
|
||
<p>z 分数原型匹配分类 · 照片上标注 face_width / face_height 及各比例特征</p>
|
||
<p>生成时间:{html.escape(now)} · 样本 {n_girl + n_man} 张(女 {n_girl} / 男 {n_man})· 成功 {n_ok} · 覆盖 {n_kind} 种脸型 · 点击图片看大图</p>
|
||
</header>
|
||
|
||
<div class="legend-box">
|
||
<div class="inner">
|
||
<b>图上标注说明</b><br/>
|
||
<span class="swatch" style="background:#00dc78"></span><code>face_width</code> 颧骨宽度
|
||
<span class="swatch" style="background:#28b4ff"></span><code>face_height</code> 额头顶→下巴
|
||
<span class="swatch" style="background:#ff5a00"></span><code>jaw_angle</code> 下巴到左右下颌角夹角
|
||
<span class="swatch" style="background:#ffc828"></span><code>taper_ratio</code> 额头→下巴收窄
|
||
<span class="swatch" style="background:#28a0ff"></span><code>forehead / jaw / chin ratio</code> 各级宽度比
|
||
<span class="swatch" style="background:#b4ff50"></span><code>face_curve_score</code> 下颌中点→下巴
|
||
右侧柱状条示意 <code>width_uniformity</code>;左上角是完整数值图例。
|
||
</div>
|
||
</div>
|
||
|
||
<div class="stats">
|
||
<div class="stat"><h2>全部脸型分布</h2>{count_table(summary['all'])}</div>
|
||
<div class="stat"><h2>女性脸型分布</h2>{count_table(summary['女'])}</div>
|
||
<div class="stat"><h2>男性脸型分布</h2>{count_table(summary['男'])}</div>
|
||
</div>
|
||
|
||
<section>
|
||
<h2>女性样本({n_girl})</h2>
|
||
<div class="grid">{girl_cards}</div>
|
||
</section>
|
||
|
||
<section>
|
||
<h2>男性样本({n_man})</h2>
|
||
<div class="grid">{man_cards}</div>
|
||
</section>
|
||
|
||
<footer>
|
||
分类实现:face/face_shape_classifier.py · 报告生成:face/build_report.py
|
||
</footer>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
|
||
def main() -> None:
|
||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||
rows, summary = analyze_all()
|
||
OUT_HTML.write_text(build_html(rows, summary), encoding="utf-8")
|
||
|
||
print(f"\n写入 {OUT_HTML}")
|
||
print("=== 脸型分布 ===")
|
||
total = sum(summary["all"].values())
|
||
for shape in SHAPE_ORDER:
|
||
n = summary["all"].get(shape, 0)
|
||
print(f" {shape}: {n:2d} ({n / total * 100:4.1f}%) {'#' * n}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|