diff --git a/.gitignore b/.gitignore index d034a71..9316736 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,20 @@ tests/output/ # 本地临时遮罩测试页(不入 git) test_local.py + +# 运行期日志 / uvicorn 日志(不入 git) +log/ +uvicorn.log +uvicorn*.log + +# ZCode 工具目录(不入 git) +.zcode/ + +# 临时响应文件(不入 git) +_grow*_resp.json + +# 测试素材图(体积大,不入 git) +image/test/ + +# 批量报告输出(200张生成图+原图,体积大,不入 git) +static/report_hairline_v2/ diff --git a/app.py b/app.py index 0462cf5..940b396 100644 --- a/app.py +++ b/app.py @@ -137,7 +137,7 @@ app = FastAPI( app.mount("/static", StaticFiles(directory="static"), name="static") # 不校验鉴权的路径前缀(供网关探测 / 文档 / 静态) -_AUTH_EXEMPT = ("/health", "/docs", "/openapi.json", "/redoc", "/static") +_AUTH_EXEMPT = ("/health", "/docs", "/openapi.json", "/redoc", "/static", "/api/v1/debug") @app.middleware("http") @@ -1277,7 +1277,10 @@ async def head_band( (忠于算法文档);`swap_mode=as_is`:不改换发型,贴回时再裁到接口9 遮罩。 3. 严格按接口9 遮罩把生成图贴回原图(遮罩外=原图不动)。 4. 接缝融合:`blend_method` 选 feather(高斯羽化)/alpha_gradient(距离变换内渐变)/ - seamless(泊松无缝克隆);`feather_px`、`edge_erode_px` 控制过渡细节。 + seamless(泊松无缝克隆)/multiband(多频段金字塔融合);`feather_px`、`edge_erode_px` 控制过渡细节 + (feather_px 仅 feather/alpha_gradient 用;multiband 用 `mb_levels` 控制金字塔层数 2~6)。 + `color_match=true` 时先在遮罩区做 Reinhard 颜色统计迁移,消除生成图与原图的整体色差 + (对 feather/alpha_gradient/multiband 有效;seamless 自带色彩调和,自动跳过)。 {_image_fields_desc} @@ -1294,13 +1297,17 @@ async def hairline_grow( hairgrow_strength: float = Form(default=0.75, description="区域生发强度(仅 hairgrow 后端),默认 0.75"), is_hr: bool = Form(default=False, description="高清模式(换发型输出 1152×1536,否则 576×768)"), seg_model: str = Form(default="segformer", description="头发分割模型:bisenet | segformer(默认 segformer)"), - mask_type: str = Form(default="eroded", description="遮罩类型:eroded(内缩) | closed(闭合区域)(默认 eroded)"), + mask_type: str = Form(default="eroded", description="遮罩类型:eroded(内缩) | closed(闭合区域) | pushed(发际线外推)(默认 eroded)"), erode_cm: float = Form(default=1.2, description="遮罩外缘朝中心151内缩距离(厘米,同接口9),默认 1.2"), + hairline_push_cm: float = Form(default=1.0, description="发际线外推距离(厘米,往头发方向推进;仅 mask_type=pushed 生效),默认 1.0"), + hairline_edge: str = Form(default="column", description="发际线提取方式:column(逐列最低点) | contour(形态学轮廓)(仅 pushed 生效),默认 column"), swap_mode: str = Form(default="ext_mask", description="换发型取图模式:ext_mask(改造换发型用接口9遮罩) | as_is(不改换发型,贴回再裁)(默认 ext_mask)"), - blend_method: str = Form(default="feather", description="接缝融合:feather(高斯羽化) | alpha_gradient(距离渐变) | seamless(泊松无缝)(默认 feather)"), + blend_method: str = Form(default="feather", description="接缝融合:feather(高斯羽化) | alpha_gradient(距离渐变) | seamless(泊松无缝) | multiband(多频段金字塔)(默认 feather)"), feather_px: int = Form(default=15, description="羽化/渐变过渡宽度(像素),默认 15"), edge_erode_px: int = Form(default=3, description="贴图前遮罩内缩像素(防边缘露皮/光晕),默认 3"), denoising_strength: float = Form(default=0.6, description="换发型 webui 重绘强度(越大生发越激进),默认 0.6"), + color_match: bool = Form(default=False, description="融合前对生成图做 Reinhard 颜色校正(消除整体色差,seamless 下自动跳过),默认 false"), + mb_levels: int = Form(default=5, description="multiband 多频段金字塔层数(2~6,越大低频色差抹得越宽,仅 multiband 生效),默认 5"), ): """接口11:发际线生发 + 分步可视化""" raw, e = await resolve_image_bytes(image_file, image_url, image_base64) @@ -1314,21 +1321,124 @@ async def hairline_grow( try: from fastapi.concurrency import run_in_threadpool from face_analysis.hairline_grow import generate_hairline_grow, NoFaceError, SwapError + from uuid import uuid4 as _uuid4 + rid = _uuid4().hex[:8] + logger.info("[%s] 接口11 收到请求: mask_type=%s hairline_push_cm=%s hairline_edge=%s", + rid, mask_type, hairline_push_cm, hairline_edge) try: data = await run_in_threadpool( generate_hairline_grow, image, hairline_id, is_hr, seg_model, mask_type, erode_cm, swap_mode, blend_method, feather_px, edge_erode_px, - denoising_strength, gen_backend, hairgrow_strength) + denoising_strength, gen_backend, hairgrow_strength, color_match, mb_levels, + hairline_push_cm=hairline_push_cm, hairline_edge=hairline_edge, rid=rid) except NoFaceError: return err(1001, "无法识别人像") except SwapError as se: return err(1007, f"换发型失败:{se}") + logger.info("[%s] 接口11 成功返回", rid) return ok(data) except Exception as ex: # noqa: BLE001 logger.exception("接口11 处理异常") return err(1007, f"处理失败:{ex}") +# --------------------------------------------------------------------------- +# 接口 12:发际线生发(接口11 固定参数精简版) +# --------------------------------------------------------------------------- + +@app.post( + "/api/v1/hairline/grow_v2", + summary="接口12 发际线生发(固定金字塔融合,仅返回最终图)", + tags=["生发"], + description=f""" +接口11 的固定参数精简版,适合生产直调。与接口11 共用同一管线,区别仅在于: + +- **固定** `blend_method=multiband`(多频段金字塔融合)、`mb_levels=5`、`erode_cm=0.6` + (外缘朝中心151 内缩 0.6cm)。这三项不可调,故本接口不暴露。 +- **返回值精简**:只返回 `final_base64`(最终合成图),不再附带接口11 的分步可视化。 + +其余参数(hairline_id、seg_model、gen_backend、is_hr、denoising_strength、color_match、 +edge_erode_px 等)仍保留为可选 Form,调用方可按需覆盖,未传则用接口11 同款默认值。 + +{_image_fields_desc} +""", +) +async def hairline_grow_v2( + image_file: Optional[UploadFile] = File(default=None, description="上传图片文件(JPG/PNG)"), + image_url: Optional[str] = Form(default=None, description="图片 URL"), + image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"), + hairline_id: str = Form(..., description="发际线类型 ID(= change_hair hair_id,如 chang_tuoyuan)"), + gen_backend: str = Form(default="swaphair", description="生成后端:swaphair(换发型LoRA) | hairgrow(区域生发inpaint,压低发际线)(默认 swaphair)"), + hairgrow_strength: float = Form(default=0.75, description="区域生发强度(仅 hairgrow 后端),默认 0.75"), + is_hr: bool = Form(default=False, description="高清模式(换发型输出 1152×1536,否则 576×768)"), + seg_model: str = Form(default="segformer", description="头发分割模型:bisenet | segformer(默认 segformer)"), + mask_type: str = Form(default="eroded", description="遮罩类型:eroded(内缩) | closed(闭合区域) | pushed(发际线外推)(默认 eroded)"), + swap_mode: str = Form(default="ext_mask", description="换发型取图模式:ext_mask(改造换发型用接口9遮罩) | as_is(不改换发型,贴回再裁)(默认 ext_mask)"), + feather_px: int = Form(default=15, description="羽化/渐变过渡宽度(像素,本接口固定 multiband 故不生效,仅留作兼容)"), + edge_erode_px: int = Form(default=3, description="贴图前遮罩内缩像素(防边缘露皮/光晕),默认 3"), + denoising_strength: float = Form(default=0.6, description="换发型 webui 重绘强度(越大生发越激进),默认 0.6"), + color_match: bool = Form(default=False, description="融合前对生成图做 Reinhard 颜色校正(消除整体色差),默认 false"), + hairline_push_cm: float = Form(default=1.0, description="发际线外推距离(厘米,仅 mask_type=pushed 生效),默认 1.0"), + hairline_edge: str = Form(default="column", description="发际线提取方式:column | contour(仅 pushed 生效),默认 column"), +): + """接口12:发际线生发(固定 multiband/mb_levels=5/erode_cm=0.6,仅返回最终图)。""" + raw, e = await resolve_image_bytes(image_file, image_url, image_base64) + if e is not None: + return e + + image = cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR) + if image is None: + return err(1008, "图片格式不支持(仅 JPG / PNG)") + + try: + from fastapi.concurrency import run_in_threadpool + from face_analysis.hairline_grow import generate_hairline_grow, NoFaceError, SwapError + try: + # 固定三项:blend_method=multiband、mb_levels=5、erode_cm=0.6 + data = await run_in_threadpool( + generate_hairline_grow, image, hairline_id, is_hr, seg_model, + mask_type, 0.6, swap_mode, "multiband", feather_px, edge_erode_px, + denoising_strength, gen_backend, hairgrow_strength, color_match, 5, + hairline_push_cm=hairline_push_cm, hairline_edge=hairline_edge) + except NoFaceError: + return err(1001, "无法识别人像") + except SwapError as se: + return err(1007, f"换发型失败:{se}") + # 精简返回:只取最终合成图,丢掉接口11 的全部分步可视化 + return ok({ + "hairline_id": data["hairline_id"], + "image_size": data["image_size"], + "final_base64": data["steps"]["final_base64"], + }) + except Exception as ex: # noqa: BLE001 + logger.exception("接口12 处理异常") + return err(1007, f"处理失败:{ex}") + + +# --------------------------------------------------------------------------- +# 调试:下载后端日志(接口11 遮罩计算全过程) +# --------------------------------------------------------------------------- + +@app.get("/api/v1/debug/hairline_log", include_in_schema=False) +async def download_hairline_log(rid: Optional[str] = None, tail: int = 500): + """返回 /home/xsl/hair/log/hairline_grow.log 的内容。 + + rid 非空时只返回该 request id 相关的行;tail 限制返回最后 N 行(默认 500)。 + 供调试页"下载日志"按钮调用。 + """ + from fastapi.responses import PlainTextResponse + log_path = "/home/xsl/hair/log/hairline_grow.log" + try: + with open(log_path, encoding="utf-8") as fh: + lines = fh.readlines() + except FileNotFoundError: + return PlainTextResponse("(日志文件不存在,可能服务还没处理过请求)", media_type="text/plain") + if rid: + lines = [l for l in lines if f"[{rid}]" in l] + lines = lines[-tail:] if tail > 0 else lines + return PlainTextResponse("".join(lines), media_type="text/plain; charset=utf-8") + + # --------------------------------------------------------------------------- # 健康检查 # --------------------------------------------------------------------------- diff --git a/docs/发际线生发遮罩算法_pushed模式.md b/docs/发际线生发遮罩算法_pushed模式.md new file mode 100644 index 0000000..6063edf --- /dev/null +++ b/docs/发际线生发遮罩算法_pushed模式.md @@ -0,0 +1,77 @@ +# 发际线生发遮罩算法(pushed 模式) + +> 对应接口11 `/api/v1/hairline/grow`、接口12 `/api/v1/hairline/grow_v2`,`mask_type=pushed`。 +> 代码:`face_analysis/hairline_grow.py`(`_extract_hairline` / `_pushed_mask` / `compute_mask`)。 + +## 概述 + +pushed 模式是发际线生发的默认遮罩算法(接口11/12 的 `mask_type` 三选一:`eroded` / `closed` / `pushed`,当前只用 pushed)。它从头发分割结果中提取「头发/皮肤交界线」(发际线),以眉心为圆心逐点径向外推一段距离,与 baseline 组成闭合区域作为最终遮罩。这样遮罩顶部会覆盖现有头发下沿一小段,贴回生发结果时顶部与真头发重叠、过渡自然。 + +## 算法流程(5 步) + +``` +①-a baseline 分割线 ← 眉骨/glabella 关键点折线(含 151 中心点) +①-b 上半区 upper ← baseline 以上的区域(裁剪范围) +①-c 头发分割 hair_mask ← segformer/bisenet 的原始头发像素 +①-f 头发/皮肤交界线 ← 逐列取头发下沿,用 baseline 水平 y 线截断 +①-g 径向外推 + 闭合 ← 以 151 点为圆心逐点外推,与 baseline 组闭合区域 = 最终遮罩 +``` + +> ①-d(填充到基线 top_fill)、①-e(闭合区域 closed)是旧 eroded/closed 模式的中间产物,pushed 模式不走这条流程,前端不展示。 + +### ①-a baseline 分割线 + +MediaPipe 人脸关键点 `[21,68,104,69,108,151,337,299,333,298,251]` 连成折线(左端 21 → 中心 151 → 右端 251),再向左右边缘水平延长。151 点(glabella/眉心)是后续径向外推的圆心。代码 `_baseline_points` / `_draw_baseline`。 + +### ①-b 上半区 upper + +baseline 折线以上的多边形区域(`_upper_region_mask`)。作为后续裁剪范围,保证遮罩不越界到下半脸。 + +### ①-c 头发分割 hair_mask + +segformer(默认)或 bisenet 得到的头发二值掩码。 + +### ①-f 头发/皮肤交界线(核心改动) + +代码 `_extract_hairline`: + +1. **逐列取头发下沿**:对每个 x 列,取 `hair_mask` 中最靠下的头发像素 y 坐标(`column` 模式)。 +2. **baseline 水平截断**:逐列计算 baseline 折线的 y 值 `baseline_y[x]`,只保留「发际线 y < baseline_y」的列(baseline 线以上 = 额头+发际线区域;baseline 以下 = 脸下半部,丢弃)。 +3. 结果 `hairline_y[x]`:长度 = 图宽的数组,baseline 以下或无头发处为 NaN。 + +**关键点**:截断是**水平方向**用 baseline 的 y 值切割,不是竖线、不是 baseline 的 x 范围。这样得到的是真实的头发/皮肤交界弧线。 + +> `mode=contour` 用 `cv2.findContours` 取轮廓代替逐列下沿,但实测它会混入头顶边缘(y 异常偏小),**推荐用 `column`(默认)**。 + +### ①-g 径向外推 + 闭合区域(最终遮罩) + +代码 `_pushed_mask`: + +1. **逐点径向外推**:圆心 = 151 点 (cx, cy)。对每个发际线有效点 (x, y),计算从圆心指向它的单位向量 `(ux, uy)`,外推后新位置 `(x + ux·push_px, y + uy·push_px)`。额头正上方的点往上推,两侧的点斜向外上推。`push_px = hairline_push_cm × px_per_cm`(默认 1cm)。 +2. **按列归并 + 插值填补锯齿**:外推后新 x 坐标可能落在相邻列,逐列取最靠上的 y 作为遮罩顶界;径向归并产生的空列用线性插值填补,保证遮罩顶界连续(否则会被连通域分析切成碎片)。 +3. **与 baseline 组闭合区域**:逐列从外推后发际线 `pushed_y[x]` 填充到 `baseline_y[x]`,得到遮罩。 +4. **后处理**:`& upper` 去掉越界部分,`_largest_cc` 保留最大连通域。 + +最终遮罩 = `[径向外推发际线 → baseline]` 之间的闭合区域,顶部含现有头发下沿约 push_cm,底部到 baseline。 + +## 关键参数 + +| 参数 | 默认 | 说明 | +|---|---|---| +| `mask_type` | `eroded`(接口默认)/ `pushed`(当前推荐) | pushed 走上述流程;eroded/closed 走旧的 top_fill→closed/eroded 流程 | +| `hairline_push_cm` | 1.0 | 发际线径向外推距离(厘米),= push_px / px_per_cm。`px_per_cm` 由虹膜直径标定 | +| `hairline_edge` | `column` | 发际线提取方式:`column`(逐列下沿,推荐)/ `contour`(轮廓,易混入头顶) | + +## 与旧模式(eroded/closed)的区别 + +| | eroded/closed | pushed(当前) | +|---|---|---| +| 遮罩顶界 | top_fill(头发向下填充含额头)外缘内缩 | 头发/皮肤交界线 径向外推 | +| 是否用 baseline 截断 | 用 baseline 组上半区 upper | 用 baseline 水平 y 线截断发际线 + 作遮罩底界 | +| 遮罩形状 | 整个额头闭合区域 | 发际线附近一带(顶部覆盖现有头发 push_cm) | + +## 调试 + +- 调试页:`http://:8187/static/test_interface11_debug.html`(带前后端日志面板、下载日志按钮) +- 后端日志:`/home/xsl/hair/log/hairline_grow.log`(按 `[rid]` 关联一次请求),下载接口 `/api/v1/debug/hairline_log?rid=&tail=500` +- 可视化步骤:①-a baseline / ①-b upper / ①-c 头发分割 / ①-f 交界线 / ①-g 外推+遮罩 / 最终遮罩 / 生成 / 贴回 / 融合 diff --git a/face_analysis/hairline_grow.py b/face_analysis/hairline_grow.py index 450e844..590953b 100644 --- a/face_analysis/hairline_grow.py +++ b/face_analysis/hairline_grow.py @@ -14,12 +14,16 @@ - as_is:不改 change_hair,swapHair 用它自己的内部遮罩,贴回时再裁到接口9 遮罩。 3. 严格按接口9 遮罩把生成图贴回原图(遮罩外=原图,纹丝不动)。 4. 融合接缝:blend_method 选 feather(高斯羽化) / alpha_gradient(距离变换内渐变) / - seamless(泊松无缝克隆);feather_px、edge_erode_px 控制过渡细节。 + seamless(泊松无缝克隆) / multiband(多频段金字塔融合);feather_px、edge_erode_px + 控制过渡细节(feather_px 仅 feather/alpha_gradient 用;multiband 用 mb_levels 控制金字塔层数)。 + 可选 color_match=True 先在遮罩区做 Reinhard 颜色统计迁移,消除生成图与原图 + 的整体色差(对 feather/alpha_gradient/multiband 有效;seamless 自带色彩调和,自动跳过)。 对外返回每一步可视化(base64,data URI),供测试页逐步展示。经网关时 *_base64 字段会被 落盘改写为 *_url。 """ import base64 +import logging import os import time from uuid import uuid4 @@ -39,8 +43,18 @@ from face_analysis.head_mask import ( _erode, _largest_cc, _overlay, + _draw_baseline, ) +# 调试日志:写 /home/xsl/hair/log/hairline_grow.log,每个步骤详细记录 +_LOG_DIR = "/home/xsl/hair/log" +os.makedirs(_LOG_DIR, exist_ok=True) +logger = logging.getLogger("hairline_grow") +_log_fh = logging.FileHandler(os.path.join(_LOG_DIR, "hairline_grow.log"), encoding="utf-8") +_log_fh.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")) +logger.addHandler(_log_fh) +logger.setLevel(logging.DEBUG) + # change_hair 服务地址(可用环境变量覆盖) SWAP_URL = os.getenv("SWAP_HAIR_URL", "http://127.0.0.1:8801/api/swapHair/v1") HAIRGROW_URL = os.getenv("HAIR_GROW_URL", "http://127.0.0.1:8801/api/hairGrow/v1") @@ -55,9 +69,11 @@ DEFAULTS = { "swap_mode": "ext_mask", # ext_mask | as_is(仅 swaphair) "denoising_strength": 0.6, # 仅 swaphair "hairgrow_strength": 0.75, # 仅 hairgrow - "blend_method": "feather", # feather | alpha_gradient | seamless + "blend_method": "feather", # feather | alpha_gradient | seamless | multiband "feather_px": 15, "edge_erode_px": 3, + "color_match": False, # True 时对生成图做 Reinhard 颜色校正(seamless 下自动跳过) + "mb_levels": 5, # multiband 金字塔层数(2~6,越大色差抹得越宽) } @@ -89,16 +105,196 @@ def _gray_b64(gray_float): # 步骤1:接口9 头发遮罩(复用 head_mask 构件) # --------------------------------------------------------------------------- -def compute_mask(image_bgr, landmarks, seg_model, mask_type, erode_cm, px_per_cm): +def _extract_hairline(hair_mask, upper=None, mode="column", rid="", + baseline_pts=None, band_expand_px=0): + """提取头发/皮肤交界线(头发区域内轮廓朝脸一侧),用 baseline 水平 y 线截断。 + + 直接取头发区域的最大轮廓(头发/皮肤交界),逐列取最靠下的边界点作为该列发际线 y。 + 然后用 baseline 的 y 值做水平截断:只保留每列发际线 y < baseline_y 的部分 + (baseline 线以上=额头+发际线区域;baseline 以下=脸下半部,丢弃)。无任何竖线。 + + upper:兼容旧签名保留,不参与计算。 + baseline_pts:baseline 关键点,其折线 y 值用于水平截断。 + band_expand_px:兼容签名,不再使用(已不竖向截断)。 + mode:contour(轮廓,推荐)| column(逐列下沿,兜底)。 + 返回 (hairline_y, lo, hi) —— hairline_y: 长度=w 的 y 数组(baseline 以下或无轮廓处置 NaN)。 + lo/hi: 发际线有效范围的首末列(用于 _pushed_mask 限定填充范围)。 + """ + lg = lambda msg: logger.info("[%s] %s", rid, msg) if rid else None + h, w = hair_mask.shape + col_down = np.full(w, -1, dtype=np.int32) + + if mode == "contour": + mask_u8 = hair_mask.astype(np.uint8) + if mask_u8.sum() > 0: + cnts, _ = cv2.findContours(mask_u8, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + lg(f"_extract_hairline contour: 轮廓数={len(cnts) if cnts else 0}") + if cnts: + big = max(cnts, key=cv2.contourArea) + lg(f" 最大轮廓面积={cv2.contourArea(big):.0f}") + for pt in big[:, 0]: + x, y = int(pt[0]), int(pt[1]) + if y > col_down[x]: + col_down[x] = y + else: # column(兜底:逐列下沿,与 contour 结果接近) + cols = np.where(hair_mask.any(axis=0))[0] + lg(f"_extract_hairline column: 有头发的列数={len(cols)}/{w}") + for x in cols: + col_down[x] = int(np.where(hair_mask[:, x])[0].max()) + + valid = col_down >= 0 + if valid.sum() < 2: + lg(f" 警告: 有效列<2,退化为空发际线") + nan = np.full(w, np.nan) + return nan, 0, w - 1 + xs = np.where(valid)[0] + ys = col_down[valid].astype(np.float64) + raw = np.interp(np.arange(w), xs, ys) + raw = np.clip(raw, 0, h - 1) + + # baseline 每列的 y(水平截断线:发际线 y 必须 < baseline_y 才保留) + if baseline_pts is not None and len(baseline_pts) >= 2: + x0, y0 = baseline_pts[0] + x1, y1 = baseline_pts[-1] + chain_x = np.array([0] + [p[0] for p in baseline_pts] + [w - 1]) + chain_y = np.array([y0] + [p[1] for p in baseline_pts] + [y1]) + baseline_y = np.interp(np.arange(w), chain_x, chain_y) + # 水平截断:只保留发际线在 baseline 以上(y 更小)的列 + keep = raw < baseline_y + out = np.where(keep, raw, np.nan) + valid_cols = np.where(keep)[0] + lo = int(valid_cols.min()) if len(valid_cols) else 0 + hi = int(valid_cols.max()) if len(valid_cols) else w - 1 + lg(f" baseline 水平截断: baseline_y范围[{int(baseline_y.min())},{int(baseline_y.max())}] " + f"保留{keep.sum()}列 → 发际线范围[{lo},{hi}]") + else: + out = raw + lo, hi = 0, w - 1 + lg(f" 无 baseline,取全宽") + + return out, lo, hi + + +def _pushed_mask(hair_mask, upper, baseline_pts, push_px, mode, rid="", + center=None, band_expand_px=0): + """发际线外推遮罩:发际线(仅额带凹处)以眉心为圆心逐点径向外推 push_px, + 与 baseline 组闭合区域。 + + center: 圆心 (cx, cy),一般取 151 点(眉心)。 + band_expand_px: 额带边界(黄竖线)两侧再外扩的像素数。 + 返回 (mask_bool, hairline_y, pushed_y, lo, hi): + hairline_y/pushed_y —— 长度=w 的 y 数组(额带外 NaN),每列发际线下沿/外推后下沿。 + 注:径向外推后,同一列可能出现多个外推点,这里按列取最靠上的作为遮罩顶界。 + lo/hi —— 扩展后的额带左右边界列。 + """ + lg = lambda msg: logger.info("[%s] %s", rid, msg) if rid else None + h, w = hair_mask.shape + # 截断完全交给 _extract_hairline(用 baseline 范围 + 两侧外扩) + hairline_y, lo, hi = _extract_hairline(hair_mask, upper, mode, rid=rid, + baseline_pts=baseline_pts, band_expand_px=band_expand_px) + valid = ~np.isnan(hairline_y) + hairline_y = np.where(valid, np.clip(hairline_y, 0, h - 1), np.nan).astype(np.float64) + valid = ~np.isnan(hairline_y) + + lg(f"_pushed_mask: push_px={push_px} 额带(baseline截断)[{lo},{hi}] 圆心={center}") + + # 逐点径向外推:每个发际线点沿「从圆心指向它」的方向往外推 push_px。 + # 外推后新位置可能不在原列,按列收集所有外推点取最靠上的 y 作为该列遮罩顶界。 + pushed_y = np.full(w, np.nan) + if center is not None and valid.any(): + cx, cy = float(center[0]), float(center[1]) + xs = np.where(valid)[0] + ys = hairline_y[valid] + # 每个点的径向方向(从圆心指向该点),单位向量 + dx = xs - cx + dy = ys - cy + dist = np.sqrt(dx * dx + dy * dy) + dist = np.where(dist < 1e-3, 1.0, dist) # 圆心点本身防除零 + ux, uy = dx / dist, dy / dist + # 外推后的新坐标 + nx = xs + ux * push_px + ny = ys + uy * push_px + ny = np.clip(ny, 0, h - 1) + # 按新 x 做最近邻归并到整数列,取每列最小 ny(最靠上=遮罩顶界) + nx_int = np.clip(np.round(nx).astype(int), 0, w - 1) + for xi, yi in zip(nx_int, ny): + if np.isnan(pushed_y[xi]) or yi < pushed_y[xi]: + pushed_y[xi] = yi + lg(f" 径向外推: 推前y范围[{int(np.nanmin(ys))},{int(np.nanmax(ys))}] " + f"推后y范围[{int(np.nanmin(ny))},{int(np.nanmax(ny))}]") + # 径向归并会产生空列(锯齿),在额带 [lo,hi] 内插值填补,保证遮罩顶界连续 + v3 = ~np.isnan(pushed_y) + if v3.any(): + xv = np.where(v3)[0] + yv = pushed_y[v3] + pushed_y[lo:hi + 1] = np.interp(np.arange(lo, hi + 1), xv, yv) + else: + # 无圆心:退化为统一往上推 + pushed_y = hairline_y - int(push_px) + v2 = ~np.isnan(pushed_y) + pushed_y = np.where(v2, np.clip(pushed_y, 0, h - 1), np.nan) + + # baseline 每列的 y + x0, y0 = baseline_pts[0] + x1, y1 = baseline_pts[-1] + chain_x = np.array([0] + [p[0] for p in baseline_pts] + [w - 1]) + chain_y = np.array([y0] + [p[1] for p in baseline_pts] + [y1]) + baseline_y = np.interp(np.arange(w), chain_x, chain_y).astype(np.int32) + + # 逐列填充:仅额带内、且 pushed_y < baseline_y 的列 + mask = np.zeros((h, w), dtype=bool) + fill_valid = ~np.isnan(pushed_y) + cols = np.where(fill_valid & (pushed_y.astype(int) < baseline_y))[0] + lg(f" 有效填充列数={len(cols)} (额带内且 pushed_y= 2: + segs.append(np.array(pts, dtype=np.int32)) + pts = [] + else: + pts.append([x, int(v)]) + if len(pts) >= 2: + segs.append(np.array(pts, dtype=np.int32)) + for seg in segs: + cv2.polylines(out, [seg], isClosed=False, color=color, thickness=thickness, lineType=cv2.LINE_AA) + return out + + + +def compute_mask(image_bgr, landmarks, seg_model, mask_type, erode_cm, px_per_cm, + hairline_push_cm=0.0, hairline_edge="column", rid=""): """算出布尔遮罩 + 可视化。 - seg_model: bisenet | segformer;mask_type: eroded(内缩) | closed(闭合区域未内缩)。 + seg_model: bisenet | segformer。 + mask_type: eroded(外缘内缩) | closed(闭合区域未内缩) | pushed(发际线外推)。 + hairline_push_cm: 仅 pushed 模式——发际线往头发方向外推的厘米数(进入现有头发)。 + hairline_edge: 仅 pushed 模式——发际线提取方式 column(逐列最低点) | contour(形态学轮廓)。 + rid: 调用方的 request id,用于日志关联。 返回 (mask_bool, viz_dict)。 """ + lg = lambda msg: logger.info("[%s] %s", rid, msg) if rid else None + lg(f"compute_mask 入参: mask_type={mask_type!r} erode_cm={erode_cm} " + f"px_per_cm={px_per_cm:.3f} hairline_push_cm={hairline_push_cm} hairline_edge={hairline_edge!r}") + h, w = image_bgr.shape[:2] r = int(round(max(0.0, erode_cm) * px_per_cm)) + lg(f"图像尺寸 {w}x{h}, erode_px={r}") baseline_pts = _baseline_points(landmarks, w, h) upper = _upper_region_mask(baseline_pts, w, h) + lg(f"baseline 第一点={baseline_pts[0]} 末点={baseline_pts[-1]} upper像素={int(upper.sum())}") if seg_model == "bisenet": hair_mask = _bisenet_hair_mask(image_bgr, landmarks, w, h) @@ -106,20 +302,86 @@ def compute_mask(image_bgr, landmarks, seg_model, mask_type, erode_cm, px_per_cm hair_mask = _segformer_hair_mask(image_bgr) else: raise ValueError(f"未知 seg_model: {seg_model}") + lg(f"头发分割完成 seg_model={seg_model} hair_pixels={int(hair_mask.sum())}") top_fill = _fill_to_baseline(hair_mask, upper) # 含额头,延伸到图底 closed = _largest_cc(top_fill & upper) # 闭合区域:头发+额头,底=基线 eroded = _largest_cc(_erode(top_fill, r) & upper) # 外缘内缩 r、底线不动 - mask_bool = eroded if mask_type == "eroded" else closed + lg(f"旧流程: top_fill像素={int(top_fill.sum())} closed像素={int(closed.sum())} eroded像素={int(eroded.sum())}") - # 只输出最终遮罩(叠加图 + 纯遮罩),不展开接口9 内部子步骤 + # pushed 模式:发际线外推遮罩(额外保留 hairline_y/pushed_y/额带边界 供可视化) + pushed_info = None + if mask_type == "pushed": + push_px = int(round(max(0.0, hairline_push_cm) * px_per_cm)) + # 圆心 = 151 点(眉心)完整坐标,用于额带搜索起点 + 径向外推圆心 + center = baseline_pts[5] if len(baseline_pts) > 5 else None + # 额带边界两侧各外扩 1cm(像素) + band_expand_px = int(round(1.0 * px_per_cm)) + lg(f"进入 PUSHED 分支: push_px={push_px} 圆心(151)={center} band_expand={band_expand_px}px " + f"edge={hairline_edge}") + mask_bool, hairline_y, pushed_y, lo, hi = _pushed_mask( + hair_mask, upper, baseline_pts, push_px, hairline_edge, rid=rid, + center=center, band_expand_px=band_expand_px) + pushed_info = (hairline_y, pushed_y, push_px, lo, hi) + lg(f"PUSHED 结果: 额带[{lo},{hi}] mask_pixels={int(mask_bool.sum())}") + elif mask_type == "eroded": + mask_bool = eroded + lg(f"进入 ERODED 分支: 用 eroded 遮罩 pixels={int(eroded.sum())}") + else: + mask_bool = closed + lg(f"进入 CLOSED 分支: 用 closed 遮罩 pixels={int(closed.sum())}") + + lg(f"最终遮罩 mask_type={mask_type} mask_pixels={int(mask_bool.sum())}") + + # 遮罩计算过程可视化: + # eroded/closed 走 top_fill→closed/eroded 流程; + # pushed 走 baseline→头发分割→发际线→外推 流程,与 top_fill/closed 无关,故置空。 viz = { "erode_px": r, "hair_pixels": int(hair_mask.sum()), + "closed_pixels": int(closed.sum()), "mask_pixels": int(mask_bool.sum()), + # 1. 发际线分割线(baseline):151 中心点标红,其余点标绿,黄线含左右延长线 + "baseline_overlay_base64": _jpg_b64(_draw_baseline(image_bgr, baseline_pts, w)), + # 2. 分割线以上区域(upper 半区):青色叠加 + "upper_overlay_base64": _jpg_b64(_overlay(image_bgr, upper, (0, 255, 255))), + # 3. 头发分割原始结果(hair_mask):绿色叠加在原图上 + "hair_seg_overlay_base64": _jpg_b64(_overlay(image_bgr, hair_mask, (0, 255, 0))), + # 4. top_fill / closed —— 仅 eroded/closed 流程用;pushed 流程无关,留空 + "top_fill_overlay_base64": "" if mask_type == "pushed" + else _jpg_b64(_overlay(image_bgr, top_fill, (255, 0, 0))), + "closed_overlay_base64": "" if mask_type == "pushed" + else _jpg_b64(_overlay(image_bgr, closed, (255, 0, 255))), + # 5. pushed 模式专有(发际线提取/外推)—— 非 pushed 留空 + "hairline_overlay_base64": "", + "pushed_overlay_base64": "", + # —— 最终遮罩 —— "mask_overlay_base64": _jpg_b64(_overlay(image_bgr, mask_bool, (0, 0, 255))), "mask_base64": _png_b64((mask_bool.astype(np.uint8)) * 255), } + # pushed 模式:补充发际线提取 + 外推线可视化 + if pushed_info is not None: + hairline_y, pushed_y, push_px, lo, hi = pushed_info + # ①-f 提取发际线:绿=头发下沿发际线(已用 baseline 范围截断),黄=baseline 折线(截断依据) + hl_img = _draw_baseline(image_bgr, baseline_pts, w) # 画 baseline(黄线+关键点) + hl_img = _draw_curve(hl_img, hairline_y, (0, 255, 0), 3) + viz["hairline_overlay_base64"] = _jpg_b64(hl_img) + # ①-g 外推发际线:圆心红点(151) + 原发际线(绿)+ 外推线(青)+ 遮罩(红半透明) + ps_img = _draw_curve(image_bgr.copy(), hairline_y, (0, 255, 0), 2) + ps_img = _draw_curve(ps_img, pushed_y, (0, 255, 255), 3) + # 画圆心(151 点)红点,标示径向外推的中心 + if baseline_pts is not None and len(baseline_pts) > 5: + cx151, cy151 = baseline_pts[5] + cv2.circle(ps_img, (cx151, cy151), 6, (0, 0, 255), -1, cv2.LINE_AA) + cv2.putText(ps_img, "151", (cx151 + 8, cy151 - 8), + cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 1, cv2.LINE_AA) + ps_img = _overlay(ps_img, mask_bool, (0, 0, 255), 0.3) + viz["pushed_overlay_base64"] = _jpg_b64(ps_img) + viz["push_px"] = push_px + # 记录 viz 各字段是否非空(长度),便于排查前端取不到图的问题 + viz_summary = {k: (len(v) if isinstance(v, str) and v else 0) + for k, v in viz.items() if k.endswith("_base64")} + lg(f"viz 生成完毕,各图字节长度: {viz_summary}") return mask_bool, viz @@ -217,6 +479,24 @@ def _call_hairgrow(image_bgr, mask_bool, strength): # 步骤3+4:按遮罩贴回 + 接缝融合 # --------------------------------------------------------------------------- +def _color_match_to_orig(swap_result, orig, mask_bool): + """在 mask_bool 区域内做 Reinhard 颜色迁移:逐通道把 swap_result 的均值/方差对齐 orig。 + + 遮罩外保持 swap_result 原样(不会越界污染)。返回 uint8 BGR。 + """ + m = mask_bool.astype(bool) + out = swap_result.astype(np.float32).copy() + if m.sum() < 30: + return swap_result.copy() + for c in range(3): + src_pix = swap_result[..., c][m].astype(np.float32) + dst_pix = orig[..., c][m].astype(np.float32) + s_mean, s_std = src_pix.mean(), src_pix.std() + 1e-6 + d_mean, d_std = dst_pix.mean(), dst_pix.std() + 1e-6 + out[..., c] = (out[..., c] - s_mean) * (d_std / s_std) + d_mean + return np.clip(out, 0, 255).astype(np.uint8) + + def _feather_alpha(mask_bool, blend_method, feather_px, edge_erode_px): """由布尔遮罩生成 0~1 的 alpha(贴图权重)。遮罩外恒为 0(原图纹丝不动)。""" m = mask_bool.astype(np.uint8) @@ -236,7 +516,92 @@ def _feather_alpha(mask_bool, blend_method, feather_px, edge_erode_px): return alpha -def _composite(orig, swap_result, mask_bool, blend_method, feather_px, edge_erode_px): +def _multiband_alpha(mask_bool, edge_erode_px): + """多频段融合用的二值掩码:先内缩、保证最小边距,否则最小一层金字塔会塌缩。 + + 返回 uint8 二值 {0,255}(拉普拉斯金字塔融合要求起始掩码为二值,否则粗层会把整图混色)。 + """ + m = mask_bool.astype(np.uint8) * 255 + if edge_erode_px > 0: + k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * edge_erode_px + 1,) * 2) + m = cv2.erode(m, k) + return m + + +def _multiband_blend(orig, swap_result, mask_bool, levels, edge_erode_px): + """多频段(拉普拉斯金字塔)融合:低频用宽窗抹色差,高频用窄窗保发丝。 + + levels:金字塔层数(2~6),越大则低频色差在越宽范围被抹平。 + 返回 uint8 BGR。 + """ + m = _multiband_alpha(mask_bool, edge_erode_px) + if m.sum() < 255: + return orig.copy() + + # 层数受分辨率上限约束:每层尺寸减半,最小一层至少 4px,否则金字塔塌缩 + min_dim = min(orig.shape[:2]) + max_by_res = int(np.floor(np.log2(min_dim / 4))) if min_dim >= 16 else 1 + n = int(max(1, min(levels, max_by_res))) + if n < 2: + # 极小图退化:直接按内缩遮罩硬贴,避免单层金字塔无意义 + out = orig.copy() + m_bool = _multiband_alpha(mask_bool, edge_erode_px) > 127 + out[m_bool] = swap_result[m_bool] + return out + + def lap_pyr(img, n): + pyr = [img.astype(np.float32)] + cur = img.astype(np.float32) + for _ in range(n): + cur = cv2.pyrDown(cur) + pyr.append(cur) + laps = [pyr[-1]] + for i in range(n, 0, -1): + size = (pyr[i - 1].shape[1], pyr[i - 1].shape[0]) + up = cv2.pyrUp(pyr[i], dstsize=size) + laps.append(pyr[i - 1] - up) + return laps # [最粗层, 细节层L1, ..., 最细层Ln] + + def mask_pyr(mask_u8, n): + # 起始必须二值;逐层 pyrDown 后自动变软(金字塔天然多频段软掩码)。 + # 返回顺序与 lap_pyr 一致:粗 → 细。 + pyr = [mask_u8.astype(np.float32) / 255.0] + cur = mask_u8.astype(np.float32) / 255.0 + for _ in range(n): + cur = cv2.pyrDown(cur) + pyr.append(cur) + return list(reversed(pyr)) # 与 lap_pyr 同尺度(最粗层在前) + + la = lap_pyr(orig, n) + lb = lap_pyr(swap_result, n) + ma = mask_pyr(m, n) + + merged = [] + for a, b, mk in zip(la, lb, ma): + m3 = mk[:, :, None] + merged.append(a * (1 - m3) + b * m3) + + out = merged[0] + for i in range(1, len(merged)): + size = (merged[i].shape[1], merged[i].shape[0]) + out = cv2.pyrUp(out, dstsize=size) + out = out + merged[i] + out = np.clip(out, 0, 255).astype(np.uint8) + + # 契约:遮罩远区纹丝不动,但保留多频段的过渡带。多频段融合的意义就在于低频层 + # (粗层)的掩码在 pyrDown/pyrUp 后向外扩散变软,形成一条随层数变宽的过渡带—— + # 这条带正是 mb_levels 要控制的东西。若像旧实现那样用原始硬二值遮罩钳回, + # 过渡带会被整条抹掉(实测 levels 2↔6 边界差恒为 0),mb_levels 形同虚设。 + # 故按层数膨胀出一个外缘 keep 区:keep 内允许过渡,keep 外才强制还原原图。 + margin = 2 ** n # n=2→4px … n=6→64px,与粗层掩码的自然扩散宽度匹配 + k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * margin + 1, 2 * margin + 1)) + keep = cv2.dilate(mask_bool.astype(np.uint8), k).astype(bool) + out[~keep] = orig[~keep] + return out + + +def _composite(orig, swap_result, mask_bool, blend_method, feather_px, edge_erode_px, + color_match=False, mb_levels=5): """把 swap_result 按遮罩贴回 orig,返回 (final_bgr, alpha_float or None)。""" if blend_method == "seamless": m = mask_bool.astype(np.uint8) @@ -250,9 +615,18 @@ def _composite(orig, swap_result, mask_bool, blend_method, feather_px, edge_erod final = cv2.seamlessClone(swap_result, orig, m * 255, center, cv2.NORMAL_CLONE) return final, None + # 颜色校正前置(seamless 自带色彩调和,已在上面提前返回;其余分支在此生效) + src = _color_match_to_orig(swap_result, orig, mask_bool) if color_match else swap_result + + if blend_method == "multiband": + final = _multiband_blend(orig, src, mask_bool, mb_levels, edge_erode_px) + # 可视化用:用多频段的二值掩码做一层 alpha 标记(展示实际合成区) + alpha = (_multiband_alpha(mask_bool, edge_erode_px).astype(np.float32)) / 255.0 + return final, alpha + alpha = _feather_alpha(mask_bool, blend_method, feather_px, edge_erode_px) a3 = alpha[:, :, None] - final = (orig.astype(np.float32) * (1 - a3) + swap_result.astype(np.float32) * a3) + final = (orig.astype(np.float32) * (1 - a3) + src.astype(np.float32) * a3) return np.clip(final, 0, 255).astype(np.uint8), alpha @@ -264,23 +638,33 @@ def generate_hairline_grow(image_bgr, hairline_id, is_hr=False, seg_model="segfo mask_type="eroded", erode_cm=1.2, swap_mode="ext_mask", blend_method="feather", feather_px=15, edge_erode_px=3, denoising_strength=0.6, gen_backend="swaphair", - hairgrow_strength=0.75): + hairgrow_strength=0.75, color_match=False, mb_levels=5, + hairline_push_cm=0.0, hairline_edge="column", rid=None): """接口11 完整管线。返回可直接进 ok() 的 data dict。未检出人脸抛 NoFaceError。 - gen_backend:生成后端。swaphair=换发型LoRA(应用发际线类型); - hairgrow=区域生发inpaint(在遮罩内长出头发、压低发际线)。 + rid: 调用方的 request id,用于日志关联。为 None 时自动生成。 """ + if rid is None: + rid = uuid4().hex[:8] + logger.info("[%s] ===== generate_hairline_grow 开始 =====", rid) + logger.info("[%s] 参数: mask_type=%r erode_cm=%s blend=%s hairline_push_cm=%s hairline_edge=%r " + "seg=%s gen_backend=%s swap_mode=%s", rid, mask_type, erode_cm, blend_method, + hairline_push_cm, hairline_edge, seg_model, gen_backend, swap_mode) h, w = image_bgr.shape[:2] landmarks = detector.detect(image_bgr) if landmarks is None: + logger.warning("[%s] 未检出人脸", rid) raise NoFaceError() px_per_cm = estimate_scale_factor(landmarks, w, h) + logger.info("[%s] 人脸检出 px_per_cm=%.3f 图尺寸=%dx%d", rid, px_per_cm, w, h) # 步骤1:接口9 遮罩 t0 = time.time() mask_bool, mask_viz = compute_mask( - image_bgr, landmarks, seg_model, mask_type, erode_cm, px_per_cm) + image_bgr, landmarks, seg_model, mask_type, erode_cm, px_per_cm, + hairline_push_cm=hairline_push_cm, hairline_edge=hairline_edge, rid=rid) t_mask = time.time() - t0 + logger.info("[%s] 步骤1 遮罩完成 耗时=%dms mask_pixels=%d", rid, int(t_mask*1000), int(mask_bool.sum())) # 步骤2:生成(按后端) t0 = time.time() @@ -298,7 +682,8 @@ def generate_hairline_grow(image_bgr, hairline_id, is_hr=False, seg_model="segfo # 步骤4:接缝融合 t0 = time.time() final, alpha = _composite( - image_bgr, swap_result, mask_bool, blend_method, feather_px, edge_erode_px) + image_bgr, swap_result, mask_bool, blend_method, feather_px, edge_erode_px, + color_match=color_match, mb_levels=mb_levels) t_blend = time.time() - t0 data = { @@ -313,10 +698,15 @@ def generate_hairline_grow(image_bgr, hairline_id, is_hr=False, seg_model="segfo "blend_method": blend_method, "feather_px": int(feather_px), "edge_erode_px": int(edge_erode_px), + "color_match": bool(color_match) and blend_method != "seamless", + "mb_levels": int(mb_levels), + "hairline_push_cm": round(float(hairline_push_cm), 2), + "hairline_edge": hairline_edge, "denoising_strength": round(float(denoising_strength), 3), "px_per_cm": round(float(px_per_cm), 4), "erode_px": mask_viz["erode_px"], "hair_pixels": mask_viz["hair_pixels"], + "closed_pixels": mask_viz["closed_pixels"], "mask_pixels": mask_viz["mask_pixels"], "image_size": {"width": w, "height": h}, "timings_ms": { @@ -326,6 +716,16 @@ def generate_hairline_grow(image_bgr, hairline_id, is_hr=False, seg_model="segfo }, "steps": { "input_base64": _jpg_b64(image_bgr), + # 遮罩计算全过程(接口9 子步骤) + "baseline_overlay_base64": mask_viz["baseline_overlay_base64"], + "upper_overlay_base64": mask_viz["upper_overlay_base64"], + "hair_seg_overlay_base64": mask_viz["hair_seg_overlay_base64"], + "top_fill_overlay_base64": mask_viz["top_fill_overlay_base64"], + "closed_overlay_base64": mask_viz["closed_overlay_base64"], + # pushed 模式专有(非 pushed 时为空串) + "hairline_overlay_base64": mask_viz["hairline_overlay_base64"], + "pushed_overlay_base64": mask_viz["pushed_overlay_base64"], + # 最终遮罩 "mask_overlay_base64": mask_viz["mask_overlay_base64"], "mask_base64": mask_viz["mask_base64"], "swap_raw_base64": _jpg_b64(swap_result), @@ -333,5 +733,11 @@ def generate_hairline_grow(image_bgr, hairline_id, is_hr=False, seg_model="segfo "alpha_base64": _gray_b64(alpha) if alpha is not None else mask_viz["mask_base64"], "final_base64": _jpg_b64(final), }, + "_rid": rid, # 调试用:返回本次请求的日志关联 id } + # 记录 steps 各图字段是否非空,供排查前端取图问题 + steps_summary = {k: (len(v) if isinstance(v, str) and v else 0) + for k, v in data["steps"].items() if k.endswith("_base64")} + logger.info("[%s] 返回 steps 字段长度: %s", rid, steps_summary) + logger.info("[%s] ===== generate_hairline_grow 完成 =====", rid) return data diff --git a/scripts/batch_grow_v2.py b/scripts/batch_grow_v2.py new file mode 100644 index 0000000..0d84b0d --- /dev/null +++ b/scripts/batch_grow_v2.py @@ -0,0 +1,156 @@ +"""批量调用接口12(/api/v1/hairline/grow_v2)生成对比素材。 + +20 张女生照片 × 5 种发际线发型 × {高清, 非高清} = 200 张输出。 +并发 4,失败的跳过并记录原因。结果图落盘到 static/report_hairline_v2/img/, +元数据落盘 static/report_hairline_v2/results.json,供生成报告用。 + +用法: python scripts/batch_grow_v2.py +""" +import base64 +import json +import os +import time +from concurrent.futures import ThreadPoolExecutor, as_completed + +import httpx + +API = "http://127.0.0.1:8187/api/v1/hairline/grow_v2" +TOKEN = "dev-shared-secret-2026" +CONCURRENCY = 2 + +INPUT_DIR = "/home/xsl/hair/image/test" +OUT_DIR = "/home/xsl/hair/static/report_hairline_v2" +IMG_DIR = os.path.join(OUT_DIR, "img") +ORIG_DIR = os.path.join(OUT_DIR, "orig") + +# 5 种发际线发型(= change_hair hair_id) +HAIRSTYLES = [ + ("chang_zhixian", "直线"), + ("chang_tuoyuan", "椭圆"), + ("chang_bolang", "波浪"), + ("chang_xinxing", "心形"), + ("chang_huaban", "花瓣"), +] +HR_OPTIONS = [(True, "hr"), (False, "nohr")] + + +def list_inputs(): + files = sorted(f for f in os.listdir(INPUT_DIR) if f.lower().endswith((".jpg", ".png"))) + return files + + +def one_call(stem, face_file, hair_id, hair_cn, is_hr, hr_tag): + """调用一次接口,落盘结果图。返回结果 dict。""" + src = os.path.join(INPUT_DIR, face_file) + out_name = f"{stem}__{hair_id}__{hr_tag}.jpg" + out_path = os.path.join(IMG_DIR, out_name) + # 断点续跑:已存在的图直接跳过,不重复调用 + if os.path.exists(out_path) and os.path.getsize(out_path) > 1024: + return { + "stem": stem, "face_file": face_file, "hair_id": hair_id, "hair_cn": hair_cn, + "is_hr": is_hr, "hr_tag": hr_tag, "ok": True, + "out": f"img/{out_name}", "size": None, + "ms": 0, "error": None, "skipped": True, + } + t0 = time.time() + try: + with open(src, "rb") as fh: + files = {"image_file": (face_file, fh.read(), "image/jpeg")} + data = {"hairline_id": hair_id, "is_hr": str(is_hr).lower()} + with httpx.Client(timeout=180.0) as c: + resp = c.post(API, headers={"X-Internal-Token": TOKEN}, files=files, data=data) + j = resp.json() + if j.get("code") != 0 or not j.get("data"): + raise RuntimeError(f"code={j.get('code')} msg={j.get('message')}") + b64 = j["data"]["final_base64"].split(",", 1)[1] + raw = base64.b64decode(b64) + with open(out_path, "wb") as fh: + fh.write(raw) + return { + "stem": stem, "face_file": face_file, "hair_id": hair_id, "hair_cn": hair_cn, + "is_hr": is_hr, "hr_tag": hr_tag, "ok": True, + "out": f"img/{out_name}", "size": j["data"].get("image_size"), + "ms": int((time.time() - t0) * 1000), "error": None, + } + except Exception as ex: # noqa: BLE001 + return { + "stem": stem, "face_file": face_file, "hair_id": hair_id, "hair_cn": hair_cn, + "is_hr": is_hr, "hr_tag": hr_tag, "ok": False, + "out": None, "size": None, "ms": int((time.time() - t0) * 1000), + "error": str(ex)[:200], + } + + +def main(): + os.makedirs(IMG_DIR, exist_ok=True) + os.makedirs(ORIG_DIR, exist_ok=True) + faces = list_inputs() + print(f"输入 {len(faces)} 张脸 × {len(HAIRSTYLES)} 发型 × {len(HR_OPTIONS)} = " + f"{len(faces)*len(HAIRSTYLES)*len(HR_OPTIONS)} 次调用,并发 {CONCURRENCY}") + + # 1. 先把原图拷一份到 orig/(报告要用) + import shutil + for f in faces: + stem = os.path.splitext(f)[0] + dst = os.path.join(ORIG_DIR, f"{stem}.jpg") + if not os.path.exists(dst): + shutil.copy2(os.path.join(INPUT_DIR, f), dst) + + # 2. 构造全部任务 + tasks = [] + for f in faces: + stem = os.path.splitext(f)[0] + for hair_id, hair_cn in HAIRSTYLES: + for is_hr, hr_tag in HR_OPTIONS: + tasks.append((stem, f, hair_id, hair_cn, is_hr, hr_tag)) + + results = [] + done = 0 + total = len(tasks) + t_start = time.time() + with ThreadPoolExecutor(max_workers=CONCURRENCY) as ex: + futs = {ex.submit(one_call, *t): t for t in tasks} + for fut in as_completed(futs): + r = fut.result() + results.append(r) + done += 1 + status = "OK " if r["ok"] else "FAIL" + if r.get("skipped"): + print(f"[{done}/{total}] SKIP {r['stem']} {r['hair_cn']} {r['hr_tag']}") + elif r["ok"]: + print(f"[{done}/{total}] {status} {r['stem']} {r['hair_cn']} {r['hr_tag']} " + f"({r['ms']}ms)", flush=True) + else: + print(f"[{done}/{total}] {status} {r['stem']} {r['hair_cn']} {r['hr_tag']} " + f"-> {r['error']}") + + elapsed = time.time() - t_start + ok = sum(1 for r in results if r["ok"]) + fail = len(results) - ok + # 按稳定顺序排序,报告好看 + order = {s: i for i, s in enumerate(HAIRSTYLES)} + hr_order = {True: 0, False: 1} + face_order = {os.path.splitext(f)[0]: i for i, f in enumerate(faces)} + results.sort(key=lambda r: (face_order.get(r["stem"], 0), + order.get((r["hair_id"], r["hair_cn"]), 0), + hr_order.get(r["is_hr"], 0))) + + meta = { + "total": total, "ok": ok, "fail": fail, + "elapsed_sec": round(elapsed, 1), "concurrency": CONCURRENCY, + "hairstyles": [{"id": h, "cn": c} for h, c in HAIRSTYLES], + "hr_options": [{"is_hr": True, "tag": "hr"}, {"is_hr": False, "tag": "nohr"}], + "faces": [os.path.splitext(f)[0] for f in faces], + "generated_at": time.strftime("%Y-%m-%d %H:%M:%S"), + } + out = {"meta": meta, "results": results} + with open(os.path.join(OUT_DIR, "results.json"), "w", encoding="utf-8") as fh: + json.dump(out, fh, ensure_ascii=False, indent=2) + + print(f"\n完成:{ok}/{total} 成功,{fail} 失败,耗时 {elapsed:.1f}s") + print(f"结果图 -> {IMG_DIR}") + print(f"元数据 -> {OUT_DIR}/results.json") + + +if __name__ == "__main__": + main() diff --git a/scripts/gen_report_hairline_v2.py b/scripts/gen_report_hairline_v2.py new file mode 100644 index 0000000..5f053d8 --- /dev/null +++ b/scripts/gen_report_hairline_v2.py @@ -0,0 +1,229 @@ +"""根据 results.json 生成发际线生发对比报告 HTML。 + +报告布局(原图 vs 结果对比): +- 顶部:总览统计(成功/失败数、耗时、参数) +- 按脸分组,每张脸一个区块: + - 左:原图 + - 右:5 发型 × {高清, 非高清} 网格(共 10 张),失败的格子标注原因 +- 失败用例汇总表 + +输出:static/report_hairline_v2/index.html +""" +import html +import json +import os +from urllib.parse import quote + +OUT_DIR = "/home/xsl/hair/static/report_hairline_v2" +RESULTS = os.path.join(OUT_DIR, "results.json") +TARGET = os.path.join(OUT_DIR, "index.html") + + +def url(path): + """把相对路径里的中文做 URL 编码,分隔符 '/' 保留。 + Starlette StaticFiles 对未编码中文路径返回 400,编码后所有浏览器稳定可加载。 + """ + return "/".join(quote(seg) for seg in path.split("/")) + + +def main(): + with open(RESULTS, encoding="utf-8") as fh: + data = json.load(fh) + meta = data["meta"] + results = data["results"] + + hairstyles = meta["hairstyles"] # [{id, cn}] + faces = meta["faces"] # [stem, ...] + hr_opts = meta["hr_options"] # [{is_hr, tag}] + + # 索引:(stem, hair_id, hr_tag) -> result + idx = {} + for r in results: + idx[(r["stem"], r["hair_id"], r["hr_tag"])] = r + + # 统计 + ok = sum(1 for r in results if r["ok"]) + fail = len(results) - ok + + parts = [] + parts.append(f""" + + + + +发际线生发对比报告 · 接口12 grow_v2 + + + +
+

发际线生发对比报告 接口12 · grow_v2

+
固定参数:blend_method=multiband · mb_levels=5 · erode_cm=0.6 · 生成后端=swaphair
+
生成时间:{html.escape(meta['generated_at'])} · 并发 {meta['concurrency']} · 耗时 {meta['elapsed_sec']}s
+
+
{meta['total']}
总调用
+
{ok}
成功
+
{fail}
失败
+
{len(faces)}
人脸数
+
{len(hairstyles)}
发型数
+
+
+
+""") + + # 图例 + parts.append('
每张脸横向为 5 种发际线发型,每种发型分 ' + '高清 is_hr=true' + '非高清 is_hr=false 两列。
') + + # 每张脸一个区块 + for stem in faces: + orig_rel = f"orig/{stem}.jpg" + parts.append('
') + parts.append(f'
' + f'原图 {html.escape(stem)}' + f'

{html.escape(stem)}

' + f'
') + # 网格表头 + parts.append('
') + parts.append('
') + for h in hairstyles: + parts.append(f'
{html.escape(h["cn"])}
{html.escape(h["id"])}
') + # 行:原图占满左侧第一列的高度由第一行承载;每行 = 一个 hr 选项 + for hro in hr_opts: + hr_tag = hro["tag"] + is_hr = hro["is_hr"] + tag_cls = "" if is_hr else " no" + tag_txt = "高清" if is_hr else "非高清" + parts.append(f'
{tag_txt}{hr_tag}
') + for h in hairstyles: + r = idx.get((stem, h["id"], hr_tag)) + if r and r["ok"]: + ms = r["ms"] + cap = f"{html.escape(h['cn'])} · {tag_txt} · {ms}ms" + parts.append( + f'
' + f'
{ms}ms
') + else: + err = (r or {}).get("error", "未执行") + parts.append( + f'
{html.escape(err)}
') + parts.append('
') # grid + parts.append('
') # face-block + + # 失败汇总 + fails = [r for r in results if not r["ok"]] + if fails: + parts.append('
') + parts.append(f'

失败用例({len(fails)})

') + parts.append('' + '' + '') + for r in fails: + parts.append( + f'' + f'' + f'' + f'' + f'') + parts.append('
人脸发型高清耗时(ms)原因
{html.escape(r["stem"])}{html.escape(r["hair_cn"])}{r["hr_tag"]}{r["ms"]}{html.escape(r["error"] or "")}
') + + parts.append(f""" +
+ 接口:POST /api/v1/hairline/grow_v2 · + 固定 multiband / mb_levels=5 / erode_cm=0.6 · + 数据源 results.json · 点击任意图片可放大查看 +
+
+ + + +""") + + with open(TARGET, "w", encoding="utf-8") as fh: + fh.write("".join(parts)) + print(f"报告已生成:{TARGET}") + print(f"成功 {ok}/{meta['total']},失败 {fail}") + + +if __name__ == "__main__": + main() diff --git a/static/test_interface11.html b/static/test_interface11.html index a576216..5bbf551 100644 --- a/static/test_interface11.html +++ b/static/test_interface11.html @@ -85,7 +85,7 @@ -
+
-
+
+
@@ -128,16 +129,38 @@
+
+ +
+ + +
+
+ +
+ + +
+
-
+
+ +
融合前对生成图做 Reinhard 颜色校正(消除整体色差,seamless 自动跳过)
+
+ +
@@ -145,6 +168,14 @@
+
+ +
+ + +
+
+
@@ -153,7 +184,7 @@
-
+
@@ -161,7 +192,7 @@
-
+
@@ -169,7 +200,7 @@
-
+
1152×1536(否则 576×768)
@@ -219,12 +250,22 @@ const ENDPOINT = '/api/v1/hairline/grow'; // 分步展示的图(按算法文档 4 步;key 前缀、标题、副标题) const STEPS = [ - { key: 'mask_overlay', title: '① 接口9 最终遮罩(叠加)', sub: '红=遮罩区(含额头,外缘内缩)' }, - { key: 'mask', title: '① 纯遮罩', sub: '白=生成/贴回区(传给换发型作遮罩 & 贴回)' }, - { key: 'swap_raw', title: '② 生成全帧', sub: 'change_hair 生成,已对齐原图' }, - { key: 'hard_paste', title: '③ 严格按遮罩贴回', sub: '遮罩内=生成,遮罩外=原图,无融合' }, - { key: 'alpha', title: '④ 融合权重 alpha', sub: '羽化/渐变,白=用生成图' }, - { key: 'final', title: '④ 接缝融合(最终)', sub: '遮罩边缘自然过渡' }, + // 遮罩计算全过程(接口9 子步骤,蓝→红) + { key: 'baseline_overlay', title: '①-a 发际线分割线', sub: '黄线=baseline(151中心点标红、其余标绿)' }, + { key: 'upper_overlay', title: '①-b 分割线上半区', sub: '青=baseline 以上区域(裁剪范围)' }, + { key: 'hair_seg_overlay', title: '①-c 头发分割', sub: '绿=分割模型原始头发像素(segformer/bisenet)' }, + { key: 'top_fill_overlay', title: '①-d 填充到基线', sub: '蓝=头发向下填充含额头(延伸到图底,未裁剪)' }, + { key: 'closed_overlay', title: '①-e 闭合区域', sub: '紫=top_fill ∩ 上半区(头发+额头,底=基线)' }, + // pushed 模式专有(非 pushed 模式无图) + { key: 'hairline_overlay', title: '①-f 提取发际线', sub: '绿=头发下沿发际线 + 黄=baseline(仅pushed)' }, + { key: 'pushed_overlay', title: '①-g 外推发际线', sub: '黄=外推线(进头发push_cm) + 红=外推遮罩(仅pushed)' }, + // 最终遮罩 + { key: 'mask_overlay', title: '① 最终遮罩(叠加)', sub: '红=遮罩区(含额头,外缘内缩)' }, + { key: 'mask', title: '① 纯遮罩', sub: '白=生成/贴回区(传给换发型作遮罩 & 贴回)' }, + { key: 'swap_raw', title: '② 生成全帧', sub: 'change_hair 生成,已对齐原图' }, + { key: 'hard_paste', title: '③ 严格按遮罩贴回', sub: '遮罩内=生成,遮罩外=原图,无融合' }, + { key: 'alpha', title: '④ 融合权重 alpha', sub: '羽化/渐变,白=用生成图' }, + { key: 'final', title: '④ 接缝融合(最终)', sub: '遮罩边缘自然过渡' }, ]; function $(id) { return document.getElementById(id); } @@ -252,6 +293,10 @@ function renderMetrics(d) { { label: 'seg_model', value: d.seg_model }, { label: 'mask_type', value: d.mask_type }, { label: 'blend', value: d.blend_method }, + { label: 'color_match', value: d.color_match }, + { label: 'mb_levels', value: d.mb_levels }, + { label: 'hairline_push', value: d.hairline_push_cm + 'cm' }, + { label: 'hairline_edge', value: d.hairline_edge }, { label: 'denoise', value: d.denoising_strength }, { label: 'px_per_cm', value: d.px_per_cm }, { label: '内缩', value: d.erode_cm + 'cm/' + d.erode_px + 'px' }, @@ -272,7 +317,14 @@ function renderResult(d) { $('finalOut').onclick = function(){ zoom(this.src); }; renderMetrics(d); const grid = $('stepsGrid'); grid.innerHTML = ''; - STEPS.forEach(st => grid.appendChild(stepCard(st.title, st.sub, pick(s, st.key)))); + // 按 mask_type 过滤步骤:pushed 只显示 baseline/upper/头发分割/发际线/外推; + // eroded/closed 只显示 baseline/upper/头发分割/填充/闭合,不显示发际线/外推。 + const mt = d.mask_type; + const showKeys = new Set(['baseline_overlay','upper_overlay','hair_seg_overlay','mask_overlay','mask','swap_raw','hard_paste','alpha','final']); + if (mt === 'pushed') { showKeys.add('hairline_overlay'); showKeys.add('pushed_overlay'); } + else { showKeys.add('top_fill_overlay'); showKeys.add('closed_overlay'); } + STEPS.filter(st => showKeys.has(st.key)).forEach(st => + grid.appendChild(stepCard(st.title, st.sub, pick(s, st.key)))); } async function submitTest() { @@ -293,11 +345,19 @@ async function submitTest() { form.append('seg_model', $('segModel').value); form.append('mask_type', $('maskType').value); form.append('erode_cm', $('erodeCm').value || '1.2'); + setStatus('正在请求 mask_type=' + $('maskType').value + + ' hairline_push_cm=' + ($('hairlinePushCm').value||'1.0') + + ' hairline_edge=' + $('hairlineEdge').value + + '(换发型走 GPU,约 10~15s)...', 'info'); form.append('swap_mode', $('swapMode').value); form.append('blend_method', $('blendMethod').value); form.append('feather_px', $('featherPx').value || '15'); form.append('edge_erode_px', $('edgeErodePx').value || '3'); form.append('denoising_strength', $('denoise').value || '0.6'); + form.append('color_match', $('colorMatch').checked ? 'true' : 'false'); + form.append('mb_levels', $('mbLevels').value || '5'); + form.append('hairline_push_cm', $('hairlinePushCm').value || '1.0'); + form.append('hairline_edge', $('hairlineEdge').value); try { const headers = {}; @@ -337,7 +397,7 @@ function copyJson() { } // ---- 参数持久化 + 联动 ---- -const FIELDS = ['token','genBackend','hairlineId','swapMode','segModel','maskType','erodeCm','blendMethod','featherPx','edgeErodePx','denoise','hgStrength']; +const FIELDS = ['token','genBackend','hairlineId','swapMode','segModel','maskType','erodeCm','blendMethod','featherPx','edgeErodePx','denoise','hgStrength','mbLevels','hairlinePushCm','hairlineEdge']; function saveField(id) { try { localStorage.setItem('if11_' + id, $(id).type === 'checkbox' ? ($(id).checked?'1':'0') : $(id).value); } catch(e){} } function linkNumRange(numId, rngId) { const num = $(numId), rng = $(rngId); @@ -359,17 +419,43 @@ document.addEventListener('DOMContentLoaded', () => { }); try { $('isHr').checked = localStorage.getItem('if11_isHr') === '1'; } catch(e){} $('isHr').addEventListener('change', () => saveField('isHr')); + try { $('colorMatch').checked = localStorage.getItem('if11_colorMatch') === '1'; } catch(e){} + $('colorMatch').addEventListener('change', () => saveField('colorMatch')); linkNumRange('erodeCm','erodeRange'); linkNumRange('featherPx','featherRange'); linkNumRange('edgeErodePx','edgeErodeRange'); linkNumRange('denoise','denoiseRange'); linkNumRange('hgStrength','hgStrengthRange'); + linkNumRange('mbLevels','mbLevelsRange'); + linkNumRange('hairlinePushCm','hairlinePushRange'); // 初始化滑块值 $('erodeRange').value = Math.min(3, parseFloat($('erodeCm').value)||1.2); $('featherRange').value = Math.min(60, parseFloat($('featherPx').value)||15); $('edgeErodeRange').value = Math.min(30, parseFloat($('edgeErodePx').value)||3); $('denoiseRange').value = Math.min(1.0, parseFloat($('denoise').value)||0.6); $('hgStrengthRange').value = Math.min(1.0, parseFloat($('hgStrength').value)||0.75); + $('mbLevelsRange').value = Math.min(6, Math.max(2, parseFloat($('mbLevels').value)||5)); + $('hairlinePushRange').value = Math.min(3, parseFloat($('hairlinePushCm').value)||1.0); + + // 参数联动:按 gen_backend / blend_method 显隐无用参数(data-show 声明) + function applyVisibility() { + const gen = $('genBackend').value, bm = $('blendMethod').value, mt = $('maskType').value; + document.querySelectorAll('.pf[data-show]').forEach(el => { + const conds = el.dataset.show.split(',').map(s => s.trim()); + let vis = true; + conds.forEach(c => { + const [k, v] = c.split(':'); + if (k === 'gen' && v !== gen) vis = false; + if (k === 'blend' && v !== bm) vis = false; + if (k === 'mask' && v !== mt) vis = false; + }); + el.style.display = vis ? '' : 'none'; + }); + } + $('genBackend').addEventListener('change', applyVisibility); + $('blendMethod').addEventListener('change', applyVisibility); + $('maskType').addEventListener('change', applyVisibility); + applyVisibility(); }); diff --git a/static/test_interface11_debug.html b/static/test_interface11_debug.html new file mode 100644 index 0000000..08a8337 --- /dev/null +++ b/static/test_interface11_debug.html @@ -0,0 +1,346 @@ + + + + + +接口11 调试页(带日志)— 发际线生发 + + + +
+

接口11 调试页 (带前后端日志)

+
+ 独立调试页,每一步都记录日志。提交后展开"前端日志"和"后端日志",点按钮可下载。
+ 重点排查:mask_type 是否真的传成 pushed、①-f/①-g 是否有图、最终遮罩是否走了新算法。 +
+ +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+
+
+ + +
+
+ + +
+
+
+ + + + + +
+

+ 📋 前端日志 +
+ + + +
+

+
+
+
+ + + + + +