""" 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 "

无数据

" 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"
{html.escape(shape)}" f"
" f"{n}({pct:.0f}%)
" ) 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"""
{html.escape(item['file'])}

{html.escape(item['file'])}

检测失败

{html.escape(item['error'] or '')}

""" color = SHAPE_COLORS.get(item["predicted"], "#34495e") top3 = "".join( f"
  • {html.escape(name)}{score:.1f}
  • " for name, score in item["top3"] ) feat_html = "".join( f"{html.escape(k)}{html.escape(fmt_feat(k, v))}" for k, v in item["features"].items() ) return f"""
    {html.escape(item['file'])}

    {html.escape(item['file'])}

    {html.escape(item['gender'])}

    {html.escape(item['display'])}

    匹配度 {item['score']:.1f} · 置信度 {item['confidence']:.3f}

    Top-3 得分

    标注特征数值 {feat_html}
    """ 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""" 脸型分类预测报告(特征标注)

    脸型分类预测报告

    z 分数原型匹配分类 · 照片上标注 face_width / face_height 及各比例特征

    生成时间:{html.escape(now)} · 样本 {n_girl + n_man} 张(女 {n_girl} / 男 {n_man})· 成功 {n_ok} · 覆盖 {n_kind} 种脸型 · 点击图片看大图

    图上标注说明
    face_width 颧骨宽度  face_height 额头顶→下巴  jaw_angle 下巴到左右下颌角夹角  taper_ratio 额头→下巴收窄  forehead / jaw / chin ratio 各级宽度比  face_curve_score 下颌中点→下巴  右侧柱状条示意 width_uniformity;左上角是完整数值图例。

    全部脸型分布

    {count_table(summary['all'])}

    女性脸型分布

    {count_table(summary['女'])}

    男性脸型分布

    {count_table(summary['男'])}

    女性样本({n_girl})

    {girl_cards}

    男性样本({n_man})

    {man_cards}
    """ 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()