diff --git a/app.py b/app.py index 895f30c..aed174a 100644 --- a/app.py +++ b/app.py @@ -365,6 +365,7 @@ def _run_face_measure_data(image, variant="v1"): if variant == "v6": vd = result.vertical base_px = vd["upper_court_px"] + vd["middle_court_px"] + vd["lower_court_px"] + # 接口6 是三庭:去掉顶庭相关字段(top_court_cm / ratios.top_court / landmarks.hair_top) data["four_courts"]["ratios"] = { "upper_court": round(vd["upper_court_px"] / base_px, 3), "middle_court": round(vd["middle_court_px"] / base_px, 3), @@ -375,27 +376,31 @@ def _run_face_measure_data(image, variant="v1"): result.upper_cm + result.middle_cm + result.lower_cm, 2) data["landmarks"].pop("hair_top", None) - # 七眼:从左到右共 7 段宽度(cm),仅接口1(v1)含人头最左/最右端线段。 - # eye1=左耳外段 eye2=左脸颊段 eye3=左眼 eye4=两眼间距 eye5=右眼 eye6=右脸颊段 eye7=右耳外段 - # 端线取自耳朵分割外缘(与标注图同源);某侧耳朵不可见 → 该侧端线缺失 → 对应 eye 置 null(保留键)。 - if variant != "v6": - try: + # 七眼段宽度(cm)。eye1=左耳外段 eye2=左脸颊 eye3=左眼 eye4=两眼间距 eye5=右眼 eye6=右脸颊 eye7=右耳外段。 + # eye2~eye6(5段)只用内部分点,接口1/6 共用;eye1/eye7 需耳朵分割端线,仅接口1 有。 + try: + epts = result.eyes["points"] + lcx, rcx = epts["left_cheek"][0], epts["right_cheek"][0] + pc = result.px_per_cm + inner_xs = [lcx, epts["left_outer"][0], epts["left_inner"][0], + epts["right_inner"][0], epts["right_outer"][0], rcx] + for i in range(5): + a, b = inner_xs[i], inner_xs[i + 1] + data["seven_eyes"][f"eye{i + 2}"] = ( + None if (a is None or b is None) else round((b - a) / pc, 2)) + if variant != "v6": + # 接口1 额外算 eye1/eye7(左/右耳外段),需耳朵分割端线 from face_analysis.annotation import _ear_edges_from_mask - epts = result.eyes["points"] - lcx, rcx = epts["left_cheek"][0], epts["right_cheek"][0] head_l, head_r = _ear_edges_from_mask( ear_mask, hair_mask, result.vertical["hair_top"][1], result.vertical["chin_tip"][1], lcx, rcx, (lcx + rcx) / 2) - xs = [head_l, lcx, epts["left_outer"][0], epts["left_inner"][0], - epts["right_inner"][0], epts["right_outer"][0], rcx, head_r] - pc = result.px_per_cm - for i in range(7): - a, b = xs[i], xs[i + 1] - data["seven_eyes"][f"eye{i + 1}"] = ( - None if (a is None or b is None) else round((b - a) / pc, 2)) - except Exception as seg_e: # noqa: BLE001 - logger.warning("七眼段宽度计算失败:%s", seg_e) + data["seven_eyes"]["eye1"] = ( + None if (head_l is None) else round((lcx - head_l) / pc, 2)) + data["seven_eyes"]["eye7"] = ( + None if (head_r is None) else round((head_r - rcx) / pc, 2)) + except Exception as seg_e: # noqa: BLE001 + logger.warning("七眼段宽度计算失败:%s", seg_e) return data, result, hair_mask, ear_mask @@ -547,13 +552,13 @@ async def face_measure( 输入用户正面照,返回: - 标注好四庭七眼数据的 **PNG 图片**(仅标注图层,不含人物) - 三庭(上庭/中庭/下庭)各段**厘米数值及占比**(**不含顶庭**) -- 七眼(眼宽/脸宽/两眼间距)**厘米数值及占比** +- 五眼段宽(eye2~eye6:左脸颊/左眼/两眼间距/右眼/右脸颊)**厘米数值**,另含眼宽/脸宽/两眼间距 - 四个关键分界点的**原图像素坐标**(发际线/眉心/鼻翼下缘/下巴尖) 基于接口1 的变体,与接口1 的差异: - **去顶庭**:不画头顶横线、不返回顶庭数据;`face_total_height_cm` 为三庭之和 - **竖线范围**:纵向竖线从发际线画到下巴尖(接口1 为头顶→下巴尖) -- **不画人头最左/最右端线**:仅七眼 6 点共 5 段标尺,不取头发轮廓端线(接口1 为 8 线 7 段) +- **不画人头最左/最右端线**:仅七眼 6 点共 5 段标尺(eye2~eye6),不取头发轮廓端线(接口1 为 8 线 7 段 eye1~eye7) 其余(箭头/虚线/字体/单位cm/七眼数据)与接口1 一致。 @@ -598,6 +603,7 @@ async def face_measure( "face_width_cm": 24.08, "inter_eye_distance_cm": 3.44, "ratios": {"eye_width": 0.143, "inter_eye_distance": 0.143}, + "eye2": 3.44, "eye3": 3.44, "eye4": 3.44, "eye5": 3.44, "eye6": 3.44, }, "landmarks": { "hairline": {"x": 540, "y": 430}, @@ -1050,8 +1056,10 @@ async def face_features( - `grown_image_url`:该发型的**生发图**(生发失败时为 `null`)。 - `hairline_type`:发际线类型 key。 worker 返回 `*_base64`,网关落盘后改写为 `*_url`。 -- `best_hairline_center_point`:**首个选中发型**的 middle 档发际线曲线**面部中间点**坐标, +- `best_hairline_center_point`:**首个选中发型**的 **middle 档**发际线曲线**面部中间点**坐标, 以**原图像素**为基准(左上角为原点,x 向右,y 向下)。 +- `high_hairline_center_point`:同上,**high 档**发际线中点。 +- `low_hairline_center_point`:同上,**low 档**发际线中点。 """, responses={ 200: { @@ -1068,6 +1076,8 @@ async def face_features( {"hairline_type": "flower", "image_middle_base64": "iVBORw0KGgo...", "image_high_base64": "iVBORw0KGgo...", "image_low_base64": "iVBORw0KGgo...", "grown_image_base64": None, "order": 2}, ], "best_hairline_center_point": {"x": 540, "y": 430}, + "high_hairline_center_point": {"x": 540, "y": 380}, + "low_hairline_center_point": {"x": 540, "y": 480}, "face_measure": { "face_total_height_cm": 13.76, "four_courts": { @@ -1155,10 +1165,14 @@ async def hairline_generate( if it.get("grown_png") else None), "order": it["order"], }) - c = res["best_center"] + c = res.get("best_centers") or {} + def _pt(p): + return ({"x": p[0], "y": p[1]} if p else None) data = { "hairline_images": hairline_images, - "best_hairline_center_point": ({"x": c[0], "y": c[1]} if c else None), + "best_hairline_center_point": _pt(c.get("middle")), + "high_hairline_center_point": _pt(c.get("high")), + "low_hairline_center_point": _pt(c.get("low")), } # face_measure:复用接口1测量数值(四庭/七眼 eye1~eye7/landmarks/姿态),不含标注图。 @@ -1395,8 +1409,18 @@ async def hairline_grow( edge_erode_px: int = Form(default=3, description="贴图前遮罩内缩像素(防边缘露皮/光晕),默认 3"), denoising_strength: float = Form(default=0.6, description="换发型 webui 重绘强度(越大生发越激进),默认 0.6"), mb_levels: int = Form(default=5, description="多频段金字塔层数(2~6,越大低频色差抹得越宽),默认 5"), + blend_method: str = Form(default="multiband", description="接缝融合方法:multiband(多频段金字塔,默认) | seamless(泊松无缝克隆) | two_stage(泊松→多频段两段式,大色差场景) | feather(高斯羽化) | alpha_gradient(距离变换内渐变)"), + color_match: bool = Form(default=True, description="融合前 Reinhard 颜色迁移消除整体色差(对 multiband/feather/alpha_gradient 有效;seamless/two_stage 自带调色故跳过),默认 True"), + color_match_strength: float = Form(default=1.0, description="颜色迁移强度(0~1,1=全迁移,<1 只迁移部分防过度改色),默认 1.0"), + mb_feather_px: int = Form(default=1, description="多频段最细层掩码轻羽化像素(0=不羽化,消除发丝边缘锯齿),默认 1"), + transition_band_px: int = Form(default=-1, description="keep-region 过渡带边距(-1=自动按层数 2**n,>=0 用绝对像素与层数解耦),默认 -1"), + redraw: bool = Form(default=False, description="发际线带重绘开关:开启后额外跑一条分支——外推↔发际线带重绘,final输入,swapHair/Flux-2两路对比,结果单独展示(不替换final),默认 False"), + inpainting_fill: int = Form(default=1, description="change_hair服务端重绘填充:0=保留原图(治染绿) | 1=填充噪声(默认/原始) | 2=纯色 | 3=潜变量噪声。默认 1"), + mask_blur: int = Form(default=11, description="change_hair服务端遮罩边缘模糊像素(原始11,越大颜色越易从边缘渗透),默认 11"), + mask_dilate_scale: float = Form(default=1.0, description="change_hair服务端遮罩膨胀缩放(1.0=原始核尺寸,<1收缩防越界),默认 1.0"), + comfyui_prompt: Optional[str] = Form(default=None, description="redraw Flux-2路提示词,None用默认「补充遮罩区域的头发,加一点美颜」"), ): - """接口11:发际线生发 + 分步可视化。遮罩固定 pushed、融合固定 multiband。""" + """接口11:发际线生发 + 分步可视化。遮罩固定 pushed,融合默认 multiband(可选 seamless/two_stage/feather)。""" raw, e = await resolve_image_bytes(image_file, image_url, image_base64) if e is not None: return e @@ -1410,8 +1434,12 @@ async def hairline_grow( 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 收到请求: hairline_push_cm=%s hairline_edge=%s mb_levels=%s", - rid, hairline_push_cm, hairline_edge, mb_levels) + logger.info("[%s] 接口11 收到请求: hairline_push_cm=%s hairline_edge=%s mb_levels=%s " + "blend=%s color_match=%s cm_strength=%s mb_feather_px=%s transition_band_px=%s redraw=%s " + "inpainting_fill=%s mask_blur=%s mask_dilate_scale=%s", + rid, hairline_push_cm, hairline_edge, mb_levels, blend_method, + color_match, color_match_strength, mb_feather_px, transition_band_px, redraw, + inpainting_fill, mask_blur, mask_dilate_scale) try: data = await run_in_threadpool( generate_hairline_grow, image, hairline_id, @@ -1419,7 +1447,11 @@ async def hairline_grow( edge_erode_px=edge_erode_px, denoising_strength=denoising_strength, gen_backend=gen_backend, hairgrow_strength=hairgrow_strength, mb_levels=mb_levels, hairline_push_cm=hairline_push_cm, - hairline_edge=hairline_edge, rid=rid) + hairline_edge=hairline_edge, blend_method=blend_method, + color_match=color_match, color_match_strength=color_match_strength, + mb_feather_px=mb_feather_px, transition_band_px=transition_band_px, + redraw=redraw, inpainting_fill=inpainting_fill, mask_blur=mask_blur, + mask_dilate_scale=mask_dilate_scale, comfyui_prompt=comfyui_prompt, rid=rid) except NoFaceError: return err(1001, "无法识别人像") except SwapError as se: diff --git a/docs/发际线生发遮罩算法_pushed模式.md b/docs/发际线生发遮罩算法_pushed模式.md index 5f133ad..2cd0dca 100644 --- a/docs/发际线生发遮罩算法_pushed模式.md +++ b/docs/发际线生发遮罩算法_pushed模式.md @@ -1,12 +1,12 @@ # 发际线生发遮罩算法(pushed 模式) > 对应接口11 `/api/v1/hairline/grow`、接口12 `/api/v1/hairline/grow_v2`。 -> 遮罩算法固定为 pushed,融合算法固定为 multiband(已移除其他选项)。 -> 代码:`face_analysis/hairline_grow.py`(`_extract_hairline` / `_pushed_mask` / `compute_mask`)。 +> 遮罩算法固定为 pushed;融合算法默认 multiband(多频段金字塔),接口11 可切换 seamless/two_stage/feather。 +> 代码:`face_analysis/hairline_grow.py`(`_extract_hairline` / `_pushed_mask` / `compute_mask` / `_composite`)。 ## 概述 -pushed 是发际线生发的**唯一**遮罩算法,multiband(多频段金字塔)是**唯一**融合算法。它从头发分割结果中提取「头发/皮肤交界线」(发际线),以眉心为圆心逐点径向外推一段距离,与 baseline 组成闭合区域作为最终遮罩。这样遮罩顶部会覆盖现有头发下沿一小段,贴回生发结果时顶部与真头发重叠、过渡自然。 +pushed 是发际线生发的**唯一**遮罩算法。融合算法默认 multiband(多频段金字塔),接口11 暴露 `blend_method` 可切换为 seamless(泊松)/two_stage(泊松→多频段两段式)/feather(羽化),便于对比调优。它从头发分割结果中提取「头发/皮肤交界线」(发际线),以眉心为圆心逐点径向外推一段距离,与 baseline 组成闭合区域作为最终遮罩。这样遮罩顶部会覆盖现有头发下沿一小段,贴回生发结果时顶部与真头发重叠、过渡自然。 > 接口12 `/api/v1/hairline/grow_v2` 只需传 `image` + `hairline_id`,遮罩和融合全部固定,无需任何算法选择参数。 @@ -57,14 +57,48 @@ segformer(默认)或 bisenet 得到的头发二值掩码。 ## 关键参数 -遮罩算法(pushed)和融合算法(multiband)已固定,接口不再暴露选择参数。可调的只有: +遮罩算法(pushed)固定。融合算法接口11 通过 `blend_method` 可切换(默认 multiband),其余融合参数均可调: | 参数 | 默认 | 说明 | |---|---|---| | `hairline_push_cm` | 1.0 | 内轮廓径向外推距离(厘米),= push_px / px_per_cm。`px_per_cm` 由虹膜直径标定 | | `hairline_edge` | `column` | 兼容保留的入参;内轮廓提取(轮廓+内侧判定)不再按它分支,取值不影响结果 | | `mb_levels` | 5 | 多频段金字塔层数(2~6,越大低频色差抹得越宽)| +| `blend_method` | `multiband` | 接缝融合:multiband(多频段金字塔) / seamless(泊松) / two_stage(泊松→多频段,大色差) / feather(羽化) / alpha_gradient。接口12 固定 multiband | +| `color_match` | `true` | 融合前 Reinhard 颜色迁移消除整体色差(multiband/feather/alpha_gradient 生效;seamless/two_stage 自带调色故跳过)| +| `color_match_strength` | 1.0 | 颜色迁移强度(0~1,<1 只迁移部分,防 Reinhard 过度改色)| +| `mb_feather_px` | 1 | 多频段最细层掩码轻羽化像素(0=不羽化),消除发丝边缘锯齿 | +| `transition_band_px` | -1 | keep-region 过渡带边距(-1=自动按层数 `2**n`;>=0 用绝对像素与层数解耦)| +| `edge_erode_px` | 3 | 贴图前遮罩内缩像素(防边缘露皮/光晕)| | `erode_cm` | 0.6(接口12 固定)| baseline 参考内缩距离,对 pushed 影响很小 | +| `redraw` | `false` | 发际线带重绘开关:开启后用 final(④融合图)在「外推线↔发际线」带重绘,swapHair/Flux-2 两路对比,结果单独展示(不替换 final)| +| `inpainting_fill` | 1 | change_hair 重绘填充:0=保留原图(治染绿) / 1=填充噪声(默认) / 2=纯色 / 3=潜变量噪声 | +| `mask_blur` | 11 | change_hair 遮罩边缘模糊像素(越大颜色越易从边缘渗透)| +| `mask_dilate_scale` | 1.0 | change_hair 遮罩膨胀核缩放(1.0=原始,<1 收缩防越界)| +| `comfyui_prompt` | `null` | redraw Flux-2 路提示词,null 用默认「补充遮罩区域的头发,加一点美颜」| + +> 接口12 `/api/v1/hairline/grow_v2` 只需传 `image` + `hairline_id`,遮罩和融合全部用默认值(multiband + color_match=true),不暴露算法选择参数。 + +### 融合方法选择建议 + +- **multiband**(默认):常规首选。低频抹色差、高频保发丝。需配合 `color_match=true` 消除整体色差。 +- **two_stage**:生成图与原图色差大时用。先泊松克隆统一色调,再多频段贴细节,兼顾调色与保发丝。比纯 seamless 更不易溢色。 +- **seamless**:纯泊松梯度域调和,色调统一干净,但可能整体改色/边缘溢色。 +- **feather / alpha_gradient**:单层 alpha 过渡,最轻量,但过渡带内色差不会被抹平,仅适合色差极小的场景。 + +## 发际线带重绘(redraw,接口11 可选) + +`redraw=true` 时,在主流程(④接缝融合 final)之后额外跑一条重绘分支,结果单独展示(`steps.redraw_a` / `redraw_c`),**不替换** final。 + +**重绘区域** = ①-g 外推发际线(`outer_pts`)与 ①-f 发际线(`inner_pts`)两条折线端点相连组成的带状闭合区域(宽度 ≈ `hairline_push_cm`,只覆盖发际线交界处)。 + +**两路后端对比**(输入图 + 融合基底都用 final): +- **swapHair 路**(`redraw_a`):final + 带遮罩调 change_hair → final 走 multiband 融合 +- **Flux-2 路**(`redraw_c`):final + 带遮罩调 ComfyUI(`hair_repaint.json` 工作流)→ final 走 multiband 融合。Flux-2 经 reference latent + ColorMatch 双重保色,**不易染绿** + +> `inpainting_fill` / `mask_blur` / `mask_dilate_scale` 透传 change_hair 服务端(仅影响 swapHair 路)。`comfyui_prompt` 仅影响 Flux-2 路。 +> 两路独立容错:任一路失败只跳过该路,不影响另一路和主 final。 +> ⚠️ Flux-2 路需 ComfyUI(8188)在跑;swapHair 路需 change_hair(8801)在跑。 ## 与旧模式(eroded/closed,已移除)的区别 diff --git a/docs/接口文档.md b/docs/接口文档.md index f1fcc00..ac45ce3 100644 --- a/docs/接口文档.md +++ b/docs/接口文档.md @@ -209,9 +209,11 @@ | annotated_image_url | string | 标注图层 PNG URL(透明底,仅标注线/文字,不含人物) | | face_total_height_cm | number | 面部总高度(cm)= 上庭 + 中庭 + 下庭(**不含顶庭**) | | four_courts | object | 三庭数据(上/中/下庭,各含 cm 与 ratio;**无顶庭**) | -| seven_eyes | object | 七眼数据(眼宽/脸宽/两眼间距,各含 cm 与 ratio) | +| seven_eyes | object | 七眼数据(眼宽/脸宽/两眼间距 cm + 占比 ratios + **eye2~eye6** 共 5 段宽度) | | landmarks | object | 四个关键点像素坐标(发际线/眉心/鼻翼下缘/下巴尖) | +> 接口6 是**三庭五眼**:`four_courts`/`landmarks` 不含顶庭与头顶点(无 `top_court_cm`/`hair_top`);`seven_eyes` 只含 **eye2~eye6**(左脸颊/左眼/两眼间距/右眼/右脸颊,5 段),**无 eye1/eye7**(耳外段需头发轮廓端线,仅接口1 有)。 + ### 响应示例 ```json @@ -228,7 +230,8 @@ }, "seven_eyes": { "eye_width_cm": 3.44, "face_width_cm": 24.08, "inter_eye_distance_cm": 3.44, - "ratios": { "eye_width": 0.143, "inter_eye_distance": 0.143 } + "ratios": { "eye_width": 0.143, "inter_eye_distance": 0.143 }, + "eye2": 3.0, "eye3": 3.44, "eye4": 3.44, "eye5": 3.44, "eye6": 3.0 }, "landmarks": { "hairline": { "x": 540, "y": 430 }, @@ -410,7 +413,9 @@ | 字段 | 类型 | 说明 | |------|------|------| | hairline_images | object[] | **选中发型**列表,**数量 = 所选发型数**,元素见下表 | -| best_hairline_center_point | object | **首个选中发型**的 middle 档发际线曲线「面部中间点」坐标,原图像素:`{ "x": number, "y": number }` | +| best_hairline_center_point | object \| null | **首个选中发型**的 **middle 档**发际线曲线「面部中间点」坐标,原图像素:`{ "x": number, "y": number }` | +| high_hairline_center_point | object \| null | 同上,**high 档**发际线中点(发际线偏高) | +| low_hairline_center_point | object \| null | 同上,**low 档**发际线中点(发际线偏低) | | face_measure | object \| null | **复用接口1**的四庭七眼测量**数值**(不含标注图)。独立流程,测量失败(无人脸/非正面/分割失败)时为 `null`,不影响发际线主结果。字段结构见下表 | `hairline_images` 元素: @@ -468,6 +473,8 @@ } ], "best_hairline_center_point": { "x": 540, "y": 430 }, + "high_hairline_center_point": { "x": 540, "y": 380 }, + "low_hairline_center_point": { "x": 540, "y": 480 }, "face_measure": { "face_total_height_cm": 26.76, "four_courts": { diff --git a/face_analysis/hairline_grow.py b/face_analysis/hairline_grow.py index 0e483b5..342352f 100644 --- a/face_analysis/hairline_grow.py +++ b/face_analysis/hairline_grow.py @@ -35,6 +35,7 @@ from face_analysis.detector import detector from face_analysis.calibration import estimate_scale_factor from face_analysis.head_mask import ( NoFaceError, + BASELINE_IDX, _baseline_points, _upper_region_mask, _bisenet_hair_mask, @@ -60,6 +61,10 @@ 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") SWAP_TIMEOUT = float(os.getenv("SWAP_HAIR_TIMEOUT", "300")) +# 多频段融合最细层羽化:羽化最细 FEATHER_LAYERS 层(每层核尺寸按尺度放大)。 +# 只羽最细1层效果极弱(其拉普拉斯系数幅度小),羽化 3 层才能明显软化发丝边缘锯齿。 +FEATHER_LAYERS = 3 + DEFAULTS = { "gen_backend": "swaphair", # swaphair(换发型LoRA) | hairgrow(区域生发inpaint) "is_hr": False, @@ -294,6 +299,29 @@ def _pushed_mask(hair_mask, upper, baseline_pts, push_px, rid="", +def _redraw_band_mask(inner_pts, outer_pts, h, w, rid=""): + """重绘带遮罩:把发际线(①-f 内轮廓 inner_pts)和外推发际线(①-g outer_pts) + 两条折线的端点连接成闭合多边形,填充得到带状区域,作为重绘 mask。 + + inner_pts / outer_pts 是一一对应的有序点列(outer = inner 径向外推 push_px), + 故闭合环 = inner_pts(正向)+ outer_pts(反向)首尾相接。带只覆盖「现有头发下沿 + 到外推线」这一段(在头发一侧),正好是发际线交界处需要重绘融合的窄带。 + + 返回 band_bool。 + """ + lg = lambda msg: logger.info("[%s] %s", rid, msg) if rid else None + if len(inner_pts) < 2 or len(outer_pts) < 2: + return np.zeros((h, w), dtype=bool) + # 闭合多边形:内轮廓正向 + 外推线反向,端点自然相连 + ring = np.vstack([inner_pts.astype(np.int32), outer_pts[::-1].astype(np.int32)]) + band_u8 = np.zeros((h, w), dtype=np.uint8) + cv2.fillPoly(band_u8, [ring], 255) + band = band_u8 > 0 + lg(f"_redraw_band_mask: 内轮廓点={len(inner_pts)} 外推点={len(outer_pts)} " + f"band像素={int(band.sum())}") + return band + + def compute_mask(image_bgr, landmarks, seg_model, mask_type, erode_cm, px_per_cm, hairline_push_cm=0.0, hairline_edge="column", rid=""): """算出布尔遮罩 + 可视化。 @@ -334,7 +362,9 @@ def compute_mask(image_bgr, landmarks, seg_model, mask_type, erode_cm, px_per_cm 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 + # 按值查 151 在 BASELINE_IDX 中的位置,避免列表变动后索引错位(曾硬编码 [5]) + _idx151 = BASELINE_IDX.index(151) if 151 in BASELINE_IDX else -1 + center = baseline_pts[_idx151] if _idx151 >= 0 else None # 下颌截断线:下巴关键点 152 的 y(内轮廓两侧向下画到这里为止) try: chin_y = int(round(landmarks.landmark[152].y * h)) @@ -394,15 +424,18 @@ def compute_mask(image_bgr, landmarks, seg_model, mask_type, erode_cm, px_per_cm # ①-g 外推:圆心红点(151) + 内轮廓(绿)+ 外推线(青)+ 遮罩(红半透明) ps_img = _draw_polyline(image_bgr.copy(), inner_pts, (0, 255, 0), 2) ps_img = _draw_polyline(ps_img, outer_pts, (0, 255, 255), 3) - # 画圆心(151 点)红点,标示径向外推的中心 - if baseline_pts is not None and len(baseline_pts) > 5: - cx151, cy151 = baseline_pts[5] + # 画圆心(151 点)红点,标示径向外推的中心(_idx151 上方已按值查到) + if center is not None: + cx151, cy151 = center 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 + # 重绘带用原始数据:内轮廓点 + 外推点(供 _redraw_band_mask 连端点成带) + viz["_inner_pts"] = inner_pts + viz["_outer_pts"] = outer_pts # 记录 viz 各字段是否非空(长度),便于排查前端取不到图的问题 viz_summary = {k: (len(v) if isinstance(v, str) and v else 0) for k, v in viz.items() if k.endswith("_base64")} @@ -410,15 +443,32 @@ def compute_mask(image_bgr, landmarks, seg_model, mask_type, erode_cm, px_per_cm return mask_bool, viz +def _segment_hair(image_bgr, seg_model, landmarks, w, h): + """对任意图(如 hard_paste 重绘结果)重跑头发分割,返回 bool 掩码。 + + 与 compute_mask 内部用的同一个 seg_model 逻辑(bisenet 需 landmarks, + segformer 不需要),保证第1步(原图头发)与第2步(重绘后头发)分割口径一致。 + """ + if seg_model == "bisenet": + return _bisenet_hair_mask(image_bgr, landmarks, w, h) + elif seg_model == "segformer": + return _segformer_hair_mask(image_bgr) + else: + raise ValueError(f"未知 seg_model: {seg_model}") + + # --------------------------------------------------------------------------- # 步骤2:调 change_hair 换发型 # --------------------------------------------------------------------------- -def _call_swap(image_bgr, hairline_id, is_hr, ext_mask_bool, denoising_strength): +def _call_swap(image_bgr, hairline_id, is_hr, ext_mask_bool, denoising_strength, + inpainting_fill=1, mask_blur=11, mask_dilate_scale=1.0): """调 change_hair /api/swapHair/v1,返回与输入同分辨率同对齐的换发型结果(BGR)。 ext_mask_bool 非 None 时作为 ext_mask 传入(swap_mode=ext_mask)。 denoising_strength:webui img2img 重绘强度(越大生发越激进),透传给换发型。 + inpainting_fill / mask_blur / mask_dilate_scale:服务端重绘参数(透传给 change_hair, + 默认值=服务端原始硬编码值,未传时行为不变)。详见 change_hair 文档。 """ import requests @@ -430,6 +480,9 @@ def _call_swap(image_bgr, hairline_id, is_hr, ext_mask_bool, denoising_strength) "user_img_path": "data:image/jpeg;base64," + base64.b64encode(ibuf.tobytes()).decode(), "output_format": "base64", "denoising_strength": float(denoising_strength), + "inpainting_fill": int(inpainting_fill), + "mask_blur": int(mask_blur), + "mask_dilate_scale": float(mask_dilate_scale), } if ext_mask_bool is not None: mbuf = cv2.imencode(".png", (ext_mask_bool.astype(np.uint8)) * 255)[1] @@ -500,25 +553,60 @@ def _call_hairgrow(image_bgr, mask_bool, strength): return result +_REPAINT_WORKFLOW = os.path.join(os.path.dirname(os.path.dirname(__file__)), "hair_repaint.json") + + +def _call_comfyui(image_bgr, mask_bool, prompt=None): + """调本机 ComfyUI 的 Flux-2 inpaint 工作流(hair_repaint.json),返回与输入同分辨率的 BGR。 + + 与 swapHair 的区别:ComfyUI 把「原图 VAE 编码作 reference latent + ColorMatch」双重保色, + 天生不易染色;提示词自由可调(中文)。mask 经 RGBA alpha 通道传入(透明=重绘区)。 + ComfyUI 不在线时抛 SwapError(由调用方捕获降级)。prompt=None 用工作流内置默认提示词。 + """ + import io + from hairline.mask import compose_comfy_rgba + from hairline.comfyui import run as comfyui_run, ping + + if not ping(): + raise SwapError("ComfyUI 不可达(http://127.0.0.1:8188),redraw Flux-2 路跳过") + mask_u8 = (mask_bool.astype(np.uint8)) * 255 + rgba_img = compose_comfy_rgba(image_bgr, mask_u8) # alpha=255-mask:透明=重绘区 + buf = io.BytesIO() + rgba_img.save(buf, format="PNG") + png_bytes = comfyui_run(buf.getvalue(), prompt=prompt, workflow_path=_REPAINT_WORKFLOW) + result = cv2.imdecode(np.frombuffer(png_bytes, np.uint8), cv2.IMREAD_COLOR) + if result is None: + raise SwapError("ComfyUI 结果解码失败") + if result.shape[:2] != image_bgr.shape[:2]: + result = cv2.resize(result, (image_bgr.shape[1], image_bgr.shape[0]), + interpolation=cv2.INTER_LANCZOS4) + return result + + # --------------------------------------------------------------------------- # 步骤3+4:按遮罩贴回 + 接缝融合 # --------------------------------------------------------------------------- -def _color_match_to_orig(swap_result, orig, mask_bool): +def _color_match_to_orig(swap_result, orig, mask_bool, strength=1.0): """在 mask_bool 区域内做 Reinhard 颜色迁移:逐通道把 swap_result 的均值/方差对齐 orig。 + strength 控制迁移强度:1.0=完全对齐到 orig(原行为),<1.0 只迁移部分, + 防止 Reinhard 在某些图上过度改色(如把生成发色整体拉向皮肤色)。 遮罩外保持 swap_result 原样(不会越界污染)。返回 uint8 BGR。 """ m = mask_bool.astype(bool) - out = swap_result.astype(np.float32).copy() + src_f = swap_result.astype(np.float32) + out = src_f.copy() if m.sum() < 30: return swap_result.copy() + strength = float(min(max(strength, 0.0), 1.0)) 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 + aligned = (out[..., c] - s_mean) * (d_std / s_std) + d_mean + out[..., c] = src_f[..., c] * (1.0 - strength) + aligned * strength return np.clip(out, 0, 255).astype(np.uint8) @@ -553,10 +641,17 @@ def _multiband_alpha(mask_bool, edge_erode_px): return m -def _multiband_blend(orig, swap_result, mask_bool, levels, edge_erode_px): +def _multiband_blend(orig, swap_result, mask_bool, levels, edge_erode_px, + feather_px=1, transition_band_px=-1): """多频段(拉普拉斯金字塔)融合:低频用宽窗抹色差,高频用窄窗保发丝。 levels:金字塔层数(2~6),越大则低频色差在越宽范围被抹平。 + feather_px:最细若干层掩码轻羽化像素(0=不羽化,保持硬二值)。羽化最细 FEATHER_LAYERS + 层(核尺寸按层尺度放大),消除发丝边缘 1px 硬切锯齿;粗层仍保持二值(否则粗层会 + 把整图混色)。注意:这是消除锯齿的微调,幅度有限(边界 Δ 约 1~3/255), + 不要指望它做大范围过渡——那是 mb_levels/transition_band_px 的事。 + transition_band_px:keep-region 外缘边距。-1=自动按层数 2**n(旧行为); + >=0 则用绝对像素,使过渡带宽度与金字塔层数解耦。 返回 uint8 BGR。 """ m = _multiband_alpha(mask_bool, edge_erode_px) @@ -601,6 +696,21 @@ def _multiband_blend(orig, swap_result, mask_bool, levels, edge_erode_px): lb = lap_pyr(swap_result, n) ma = mask_pyr(m, n) + # 最细层(reversed 后末元素 = 全分辨率原始二值掩码)及其下若干层轻羽化, + # 消除发丝边缘 1px 硬切锯齿。注意:多频段融合中各层都贡献边界过渡,但最细层的 + # 拉普拉斯系数幅度最小,只羽化它效果很弱(实测边界 Δ 仅 ~0.25/255)。因此对最细 + # FEATHER_LAYERS 层都做按尺度放大的羽化(越细的层核越大),才能明显软化边缘。 + # 粗层(低频)仍保持二值,否则会把整图混色,违反多频段融合的二值掩码前提。 + fp = int(max(0, feather_px)) + if fp > 0: + for li in range(1, FEATHER_LAYERS + 1): + idx = -li + if abs(idx) > len(ma): + break + scale = 2 ** (li - 1) + ksz = fp * 2 * scale + 1 + ma[idx] = cv2.GaussianBlur(ma[idx], (ksz, ksz), sigmaX=fp * scale / 2.0) + merged = [] for a, b, mk in zip(la, lb, ma): m3 = mk[:, :, None] @@ -618,33 +728,68 @@ def _multiband_blend(orig, swap_result, mask_bool, levels, edge_erode_px): # 这条带正是 mb_levels 要控制的东西。若像旧实现那样用原始硬二值遮罩钳回, # 过渡带会被整条抹掉(实测 levels 2↔6 边界差恒为 0),mb_levels 形同虚设。 # 故按层数膨胀出一个外缘 keep 区:keep 内允许过渡,keep 外才强制还原原图。 - margin = 2 ** n # n=2→4px … n=6→64px,与粗层掩码的自然扩散宽度匹配 + if transition_band_px is not None and transition_band_px >= 0: + margin = int(transition_band_px) # 与金字塔层数解耦,用绝对像素 + else: + margin = 2 ** n # n=2→4px … n=6→64px,与粗层掩码的自然扩散宽度匹配 + margin = max(0, margin) 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 _seamless_clone(orig, swap_result, mask_bool, edge_erode_px): + """泊松无缝克隆(cv2.seamlessClone NORMAL_CLONE):梯度域调和整体色调。 + + 返回调色后的整帧 uint8 BGR;掩码过小(<10px)时返回原图。 + 供 seamless 分支与 two_stage 两段式融合的第一段复用。 + """ + m = mask_bool.astype(np.uint8) + if edge_erode_px > 0: + k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * edge_erode_px + 1,) * 2) + m = cv2.erode(m, k) + if m.sum() < 10: + return orig.copy() + ys, xs = np.where(m > 0) + center = (int((xs.min() + xs.max()) / 2), int((ys.min() + ys.max()) / 2)) + return cv2.seamlessClone(swap_result, orig, m * 255, center, cv2.NORMAL_CLONE) + + 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)。""" + color_match=False, mb_levels=5, color_match_strength=1.0, + mb_feather_px=1, transition_band_px=-1): + """把 swap_result 按遮罩贴回 orig,返回 (final_bgr, alpha_float or None)。 + + blend_method: + - multiband : 多频段金字塔融合(默认) + - seamless : 泊松无缝克隆(梯度域调色,自带色彩调和,故跳过 color_match) + - two_stage : 先 seamless 统一整体色调,再 multiband 贴发丝细节(大色差场景) + - feather/alpha_gradient : 单层 alpha 过渡 + """ + # seamless / two_stage 自带梯度域色彩调和,不叠 Reinhard 颜色迁移 if blend_method == "seamless": - m = mask_bool.astype(np.uint8) - if edge_erode_px > 0: - k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * edge_erode_px + 1,) * 2) - m = cv2.erode(m, k) - if m.sum() < 10: - return orig.copy(), None - ys, xs = np.where(m > 0) - center = (int((xs.min() + xs.max()) / 2), int((ys.min() + ys.max()) / 2)) - final = cv2.seamlessClone(swap_result, orig, m * 255, center, cv2.NORMAL_CLONE) + final = _seamless_clone(orig, swap_result, mask_bool, edge_erode_px) return final, None - # 颜色校正前置(seamless 自带色彩调和,已在上面提前返回;其余分支在此生效) - src = _color_match_to_orig(swap_result, orig, mask_bool) if color_match else swap_result + if blend_method == "two_stage": + # 第一段:seamless 把整体色调拉平(生成图色调对齐到原图) + harmonized = _seamless_clone(orig, swap_result, mask_bool, edge_erode_px) + # 第二段:对调色后的结果再做 multiband 贴发丝细节(不加 color_match,避免重复改色) + final = _multiband_blend(orig, harmonized, mask_bool, mb_levels, edge_erode_px, + feather_px=mb_feather_px, + transition_band_px=transition_band_px) + alpha = (_multiband_alpha(mask_bool, edge_erode_px).astype(np.float32)) / 255.0 + return final, alpha + + # multiband / feather / alpha_gradient:先做 Reinhard 颜色迁移消除整体色差 + src = (_color_match_to_orig(swap_result, orig, mask_bool, color_match_strength) + if color_match else swap_result) if blend_method == "multiband": - final = _multiband_blend(orig, src, mask_bool, mb_levels, edge_erode_px) + final = _multiband_blend(orig, src, mask_bool, mb_levels, edge_erode_px, + feather_px=mb_feather_px, + transition_band_px=transition_band_px) # 可视化用:用多频段的二值掩码做一层 alpha 标记(展示实际合成区) alpha = (_multiband_alpha(mask_bool, edge_erode_px).astype(np.float32)) / 255.0 return final, alpha @@ -664,21 +809,42 @@ def generate_hairline_grow(image_bgr, hairline_id, is_hr=False, seg_model="segfo edge_erode_px=3, denoising_strength=0.6, gen_backend="swaphair", hairgrow_strength=0.75, mb_levels=5, - hairline_push_cm=1.0, hairline_edge="column", rid=None): + hairline_push_cm=1.0, hairline_edge="column", + blend_method="multiband", color_match=True, + color_match_strength=1.0, mb_feather_px=1, + transition_band_px=-1, redraw=False, + inpainting_fill=1, mask_blur=11, mask_dilate_scale=1.0, + comfyui_prompt=None, rid=None): """接口11 完整管线。返回可直接进 ok() 的 data dict。未检出人脸抛 NoFaceError。 - 遮罩算法固定为 pushed(发际线外推),融合算法固定为 multiband(多频段金字塔), - 不再支持其他选项。rid: 调用方的 request id,用于日志关联。为 None 时自动生成。 + 遮罩算法固定为 pushed(发际线外推)。 + 融合算法 blend_method 默认 multiband(多频段金字塔),可选 seamless(泊松)/ + two_stage(泊松→多频段两段式)/feather(羽化)/alpha_gradient(距离变换)。 + color_match 默认开启 Reinhard 颜色迁移消除整体色差(对 multiband/feather 有效)。 + redraw=True 时额外跑一条「发际线带重绘」分支: + 重绘区域 = 外推发际线↔内推发际线之间的带(以原内轮廓为中心,向头发/脸各推 push_cm)。 + 输入图 + 融合基底都用 final(④接缝融合最终图)。两路后端对比: + ② swapHair 路(final+band 重绘→final 融合) + ③ Flux-2 路(final+band 调 ComfyUI 保色重绘→final 融合) + 结果在 steps.redraw_a/redraw_c 单独展示,不替换 final。 + comfyui_prompt:Flux-2 路提示词,None 用默认「补充遮罩区域的头发,加一点美颜」。 + inpainting_fill/mask_blur/mask_dilate_scale:透传 change_hair 服务端重绘参数(默认值 + =服务端原始硬编码值,未传行为不变)。inpainting_fill=0 保留原图可治"染绿"。 + rid: 调用方的 request id,用于日志关联。为 None 时自动生成。 """ mask_type = "pushed" # 固定:只支持 pushed 遮罩算法 - blend_method = "multiband" # 固定:只支持 multiband 融合 + # blend_method 由参数传入(默认 multiband,接口11 可覆盖) if rid is None: rid = uuid4().hex[:8] logger.info("[%s] ===== generate_hairline_grow 开始 =====", rid) - logger.info("[%s] 参数(固定 mask=pushed blend=multiband): erode_cm=%s hairline_push_cm=%s " - "hairline_edge=%r mb_levels=%s seg=%s gen_backend=%s swap_mode=%s", + logger.info("[%s] 参数(固定 mask=pushed): erode_cm=%s hairline_push_cm=%s hairline_edge=%r " + "mb_levels=%s seg=%s gen_backend=%s swap_mode=%s blend=%s color_match=%s " + "cm_strength=%s mb_feather_px=%s transition_band_px=%s redraw=%s " + "inpainting_fill=%s mask_blur=%s mask_dilate_scale=%s comfyui_prompt=%r", rid, erode_cm, hairline_push_cm, hairline_edge, mb_levels, - seg_model, gen_backend, swap_mode) + seg_model, gen_backend, swap_mode, blend_method, color_match, + color_match_strength, mb_feather_px, transition_band_px, redraw, + inpainting_fill, mask_blur, mask_dilate_scale, comfyui_prompt) h, w = image_bgr.shape[:2] landmarks = detector.detect(image_bgr) if landmarks is None: @@ -701,20 +867,88 @@ def generate_hairline_grow(image_bgr, hairline_id, is_hr=False, seg_model="segfo swap_result = _call_hairgrow(image_bgr, mask_bool, hairgrow_strength) else: ext_mask = mask_bool if swap_mode == "ext_mask" else None - swap_result = _call_swap(image_bgr, hairline_id, is_hr, ext_mask, denoising_strength) + swap_result = _call_swap(image_bgr, hairline_id, is_hr, ext_mask, denoising_strength, + inpainting_fill=inpainting_fill, mask_blur=mask_blur, + mask_dilate_scale=mask_dilate_scale) t_swap = time.time() - t0 # 步骤3:严格按遮罩硬贴回(无融合,用于对比) hard_paste = image_bgr.copy() hard_paste[mask_bool] = swap_result[mask_bool] - # 步骤4:接缝融合(固定 multiband) + # 步骤4:接缝融合(默认 multiband) t0 = time.time() final, alpha = _composite( image_bgr, swap_result, mask_bool, blend_method, 0, edge_erode_px, - color_match=False, mb_levels=mb_levels) + color_match=color_match, mb_levels=mb_levels, + color_match_strength=color_match_strength, + mb_feather_px=mb_feather_px, transition_band_px=transition_band_px) t_blend = time.time() - t0 + # 步骤5(可选):发际线带重绘分支 —— 开关 redraw=True 时执行,结果单独展示。 + # 重绘区域 = 外推发际线↔内推发际线之间的带(以原内轮廓为中心,向头发/脸各推 push_cm)。 + # 输入图 + 融合基底都用 final(④接缝融合最终图)。 + redraw_viz = { + "redraw_band_overlay_base64": "", + "redraw_a_base64": "", + "redraw_c_base64": "", + } + redraw_info = {"enabled": False} + if redraw: + t0 = time.time() + logger.info("[%s] 步骤5 发际线带重绘 开始", rid) + # ① 算重绘带:发际线(内轮廓)↔外推发际线 两条折线端点相连组成的带 + inner_pts = mask_viz.get("_inner_pts") + outer_pts = mask_viz.get("_outer_pts") + push_px = int(round(max(0.0, hairline_push_cm) * px_per_cm)) + try: + band_mask = _redraw_band_mask(inner_pts, outer_pts, h, w, rid=rid) + if band_mask.sum() < 30: + raise RuntimeError("重绘带像素过少,可能内轮廓/外推线缺失") + logger.info("[%s] 步骤5 重绘带 push_px=%d band_pixels=%d", + rid, push_px, int(band_mask.sum())) + redraw_viz["redraw_band_overlay_base64"] = _jpg_b64( + _overlay(final, band_mask, (255, 0, 255))) + redraw_info = {"enabled": True, "band_pixels": int(band_mask.sum()), + "push_px": push_px} + except Exception as ex: # noqa: BLE001 + logger.exception("[%s] 步骤5 重绘带计算失败,整个重绘跳过", rid) + redraw_info = {"enabled": False, "error": f"band: {ex}"} + + # ② swapHair 路:final + band 作 ext_mask 重绘 → final 作基底融合 + if redraw_info.get("enabled"): + try: + redraw_a_raw = _call_swap(final, hairline_id, is_hr, band_mask, denoising_strength, + inpainting_fill=inpainting_fill, mask_blur=mask_blur, + mask_dilate_scale=mask_dilate_scale) + final_a, _ = _composite( + final, redraw_a_raw, band_mask, blend_method, 0, edge_erode_px, + color_match=color_match, mb_levels=mb_levels, + color_match_strength=color_match_strength, + mb_feather_px=mb_feather_px, transition_band_px=transition_band_px) + redraw_viz["redraw_a_base64"] = _jpg_b64(final_a) + logger.info("[%s] 步骤5 swapHair路完成", rid) + except Exception as ex: # noqa: BLE001 + logger.warning("[%s] 步骤5 swapHair路失败,跳过: %s", rid, ex) + redraw_info["swap_error"] = str(ex) + + # ③ Flux-2 路:final + band 调 ComfyUI(reference latent 保色,不染绿)→ final 融合 + try: + prompt = comfyui_prompt if comfyui_prompt else "补充遮罩区域的头发,加一点美颜" + redraw_c_raw = _call_comfyui(final, band_mask, prompt=prompt) + final_c, _ = _composite( + final, redraw_c_raw, band_mask, blend_method, 0, edge_erode_px, + color_match=color_match, mb_levels=mb_levels, + color_match_strength=color_match_strength, + mb_feather_px=mb_feather_px, transition_band_px=transition_band_px) + redraw_viz["redraw_c_base64"] = _jpg_b64(final_c) + logger.info("[%s] 步骤5 Flux-2路完成", rid) + except Exception as ex: # noqa: BLE001 + logger.warning("[%s] 步骤5 Flux-2路失败,跳过: %s", rid, ex) + redraw_info["c_error"] = str(ex) + + logger.info("[%s] 步骤5 发际线带重绘完成 耗时=%dms", rid, int((time.time()-t0)*1000)) + data = { "hairline_id": hairline_id, "gen_backend": gen_backend, @@ -730,6 +964,13 @@ def generate_hairline_grow(image_bgr, hairline_id, is_hr=False, seg_model="segfo "hairline_push_cm": round(float(hairline_push_cm), 2), "hairline_edge": hairline_edge, "denoising_strength": round(float(denoising_strength), 3), + "color_match": bool(color_match), + "color_match_strength": round(float(color_match_strength), 3), + "mb_feather_px": int(mb_feather_px), + "transition_band_px": int(transition_band_px), + "inpainting_fill": int(inpainting_fill), + "mask_blur": int(mask_blur), + "mask_dilate_scale": round(float(mask_dilate_scale), 3), "px_per_cm": round(float(px_per_cm), 4), "erode_px": mask_viz["erode_px"], "hair_pixels": mask_viz["hair_pixels"], @@ -759,8 +1000,13 @@ def generate_hairline_grow(image_bgr, hairline_id, is_hr=False, seg_model="segfo "hard_paste_base64": _jpg_b64(hard_paste), "alpha_base64": _gray_b64(alpha) if alpha is not None else mask_viz["mask_base64"], "final_base64": _jpg_b64(final), + # 步骤5(可选):发际线带重绘分支 —— redraw=False 时为空串 + "redraw_band_overlay_base64": redraw_viz["redraw_band_overlay_base64"], + "redraw_a_base64": redraw_viz["redraw_a_base64"], + "redraw_c_base64": redraw_viz["redraw_c_base64"], }, - "_rid": rid, # 调试用:返回本次请求的日志关联 id + "redraw": redraw_info, + "_rid": rid, # 调用方的 request id,用于日志关联。为 None 时自动生成。 } # 记录 steps 各图字段是否非空,供排查前端取图问题 steps_summary = {k: (len(v) if isinstance(v, str) and v else 0) diff --git a/face_analysis/head_mask.py b/face_analysis/head_mask.py index e65cdf5..e88b15c 100644 --- a/face_analysis/head_mask.py +++ b/face_analysis/head_mask.py @@ -22,9 +22,10 @@ import numpy as np from face_analysis.detector import detector from face_analysis.calibration import estimate_scale_factor, normalized_to_pixel -# 底部分割线关键点(图像上从左到右:左端 162 → 中心 151 → 右端 389) -# 162/389 为左右最外侧端点(向图片左右边缘水平延长);中间含 71/301 等点构成弧线 -BASELINE_IDX = [162, 71, 68, 104, 69, 108, 151, 337, 299, 333, 298, 301, 389] +# 底部分割线关键点(图像上从左到右,眉骨弧线 → 中心 151 → 右侧对称) +# 左端 104 → 中心 151 → 右端 333;首末点向图片左右边缘水平延长 +BASELINE_IDX = [104, 69, 108, 151, 337, 299, 333] +# BASELINE_IDX = [123, 116, 143, 71, 68, 104, 69, 108, 151, 337, 299, 333, 298, 301, 372, 345, 352,] CENTER_IDX = 151 # 内缩方向的目标点(额头中心) ERODE_CM = 1.2 # 外缘内缩距离(厘米,默认;可由入参覆盖) SEGFORMER_HAIR = 13 # jonathandinu/face-parsing 中 hair 类索引 diff --git a/hair_repaint.json b/hair_repaint.json new file mode 100644 index 0000000..624b4c9 --- /dev/null +++ b/hair_repaint.json @@ -0,0 +1,450 @@ +{ + "1": { + "inputs": { + "scheduler": "simple", + "steps": 6, + "denoise": 1, + "model": [ + "2", + 0 + ] + }, + "class_type": "BasicScheduler", + "_meta": { + "title": "基本调度器" + } + }, + "2": { + "inputs": { + "max_shift": 1.15, + "base_shift": 0.5, + "width": [ + "14", + 0 + ], + "height": [ + "14", + 1 + ], + "model": [ + "16", + 0 + ] + }, + "class_type": "ModelSamplingFlux", + "_meta": { + "title": "采样算法(Flux)" + } + }, + "3": { + "inputs": { + "vae_name": "flux2-vae.safetensors" + }, + "class_type": "VAELoader", + "_meta": { + "title": "加载VAE" + } + }, + "5": { + "inputs": { + "conditioning": [ + "19", + 0 + ], + "latent": [ + "13", + 0 + ] + }, + "class_type": "ReferenceLatent", + "_meta": { + "title": "参考Latent" + } + }, + "6": { + "inputs": { + "noise_seed": 808990860769642 + }, + "class_type": "RandomNoise", + "_meta": { + "title": "随机噪波" + } + }, + "7": { + "inputs": { + "width": [ + "14", + 0 + ], + "height": [ + "14", + 1 + ], + "batch_size": 1 + }, + "class_type": "EmptySD3LatentImage", + "_meta": { + "title": "空Latent图像(SD3)" + } + }, + "8": { + "inputs": { + "sampler_name": "euler" + }, + "class_type": "KSamplerSelect", + "_meta": { + "title": "K采样器选择" + } + }, + "9": { + "inputs": { + "noise": [ + "6", + 0 + ], + "guider": [ + "20", + 0 + ], + "sampler": [ + "8", + 0 + ], + "sigmas": [ + "1", + 0 + ], + "latent_image": [ + "7", + 0 + ] + }, + "class_type": "SamplerCustomAdvanced", + "_meta": { + "title": "自定义采样器(高级)" + } + }, + "10": { + "inputs": { + "samples": [ + "9", + 0 + ], + "vae": [ + "3", + 0 + ] + }, + "class_type": "VAEDecode", + "_meta": { + "title": "VAE解码" + } + }, + "13": { + "inputs": { + "pixels": [ + "44", + 0 + ], + "vae": [ + "3", + 0 + ] + }, + "class_type": "VAEEncode", + "_meta": { + "title": "VAE编码" + } + }, + "14": { + "inputs": { + "image": [ + "44", + 0 + ] + }, + "class_type": "GetImageSize+", + "_meta": { + "title": "🔧 Get Image Size" + } + }, + "16": { + "inputs": { + "unet_name": "flux2.0/flux-2-klein-9b-fp8.safetensors", + "weight_dtype": "fp8_e4m3fn" + }, + "class_type": "UNETLoader", + "_meta": { + "title": "UNet加载器" + } + }, + "17": { + "inputs": { + "filename_prefix": "ComfyUI", + "images": [ + "62", + 0 + ] + }, + "class_type": "SaveImage", + "_meta": { + "title": "保存图像" + } + }, + "19": { + "inputs": { + "guidance": 1, + "conditioning": [ + "22", + 0 + ] + }, + "class_type": "FluxGuidance", + "_meta": { + "title": "Flux引导" + } + }, + "20": { + "inputs": { + "model": [ + "2", + 0 + ], + "conditioning": [ + "5", + 0 + ] + }, + "class_type": "BasicGuider", + "_meta": { + "title": "基本引导器" + } + }, + "22": { + "inputs": { + "text": [ + "60", + 0 + ], + "clip": [ + "61", + 0 + ] + }, + "class_type": "CLIPTextEncode", + "_meta": { + "title": "CLIP文本编码" + } + }, + "26": { + "inputs": { + "image": "clipspace/clipspace-painted-masked-1784045785080.png [input]" + }, + "class_type": "LoadImage", + "_meta": { + "title": "加载图像" + } + }, + "31": { + "inputs": { + "image": [ + "26", + 0 + ] + }, + "class_type": "easy imageSize", + "_meta": { + "title": "图像尺寸" + } + }, + "32": { + "inputs": { + "aspect_ratio": "custom", + "proportional_width": [ + "31", + 0 + ], + "proportional_height": [ + "31", + 1 + ], + "fit": "letterbox", + "method": "lanczos", + "round_to_multiple": "8", + "scale_to_side": "None", + "scale_to_length": 1024, + "background_color": "#000000", + "image": [ + "26", + 0 + ], + "mask": [ + "37", + 0 + ] + }, + "class_type": "LayerUtility: ImageScaleByAspectRatio V2", + "_meta": { + "title": "LayerUtility: ImageScaleByAspectRatio V2" + } + }, + "33": { + "inputs": { + "masks": [ + "26", + 1 + ] + }, + "class_type": "Mask Fill Holes", + "_meta": { + "title": "遮罩填充漏洞" + } + }, + "36": { + "inputs": { + "masks": [ + "33", + 0 + ] + }, + "class_type": "Convert Masks to Images", + "_meta": { + "title": "遮罩到图像" + } + }, + "37": { + "inputs": { + "method": "intensity", + "image": [ + "39", + 0 + ] + }, + "class_type": "Image To Mask", + "_meta": { + "title": "图像到遮罩" + } + }, + "39": { + "inputs": { + "upscale_method": "nearest-exact", + "width": [ + "31", + 0 + ], + "height": [ + "31", + 1 + ], + "crop": "disabled", + "image": [ + "36", + 0 + ] + }, + "class_type": "ImageScale", + "_meta": { + "title": "缩放图像" + } + }, + "44": { + "inputs": { + "mask_opacity": 1, + "mask_color": "FFFF00", + "pass_through": true, + "image": [ + "32", + 0 + ], + "mask": [ + "32", + 1 + ] + }, + "class_type": "ImageAndMaskPreview", + "_meta": { + "title": "图像与遮罩预览" + } + }, + "45": { + "inputs": { + "images": [ + "44", + 0 + ] + }, + "class_type": "PreviewImage", + "_meta": { + "title": "预览图像" + } + }, + "53": { + "inputs": { + "rgthree_comparer": { + "images": [ + { + "name": "A", + "selected": true, + "url": "/api/view?filename=rgthree.compare._temp_kzrpg_00019_.png&type=temp&subfolder=&rand=0.8964945384546902" + }, + { + "name": "B", + "selected": true, + "url": "/api/view?filename=rgthree.compare._temp_kzrpg_00020_.png&type=temp&subfolder=&rand=0.6762414189274947" + } + ] + }, + "image_a": [ + "62", + 0 + ], + "image_b": [ + "26", + 0 + ] + }, + "class_type": "Image Comparer (rgthree)", + "_meta": { + "title": "图像对比" + } + }, + "60": { + "inputs": { + "text": "补充遮罩区域的头发,加一点美颜" + }, + "class_type": "JjkText", + "_meta": { + "title": "Text" + } + }, + "61": { + "inputs": { + "clip_name": "qwen_3_8b_fp8mixed.safetensors", + "type": "flux2", + "device": "default" + }, + "class_type": "CLIPLoader", + "_meta": { + "title": "加载CLIP" + } + }, + "62": { + "inputs": { + "method": "mkl", + "strength": 1, + "multithread": true, + "image_ref": [ + "26", + 0 + ], + "image_target": [ + "10", + 0 + ] + }, + "class_type": "ColorMatch", + "_meta": { + "title": "Color Match" + } + } +} \ No newline at end of file diff --git a/hairline/comfyui.py b/hairline/comfyui.py index 507cf34..8c1b02a 100644 --- a/hairline/comfyui.py +++ b/hairline/comfyui.py @@ -109,6 +109,21 @@ def run(rgba_png_bytes: bytes, timeout: float = COMFY_TIMEOUT, prompt: str = Non if prompt is not None: wf[_PROMPT_NODE]["inputs"]["text"] = prompt + # 诊断:落盘实际提交的工作流 + 输入图,便于和手动 ComfyUI 跑的对比 + try: + import os as _os + _diag = _os.path.join(_os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))), + "log", "comfyui_last_submit") + _os.makedirs(_diag, exist_ok=True) + with open(_os.path.join(_diag, "workflow.json"), "w", encoding="utf-8") as _f: + json.dump(wf, _f, ensure_ascii=False, indent=2) + with open(_os.path.join(_diag, "input.png"), "wb") as _f: + _f.write(rgba_png_bytes) + with open(_os.path.join(_diag, "prompt.txt"), "w", encoding="utf-8") as _f: + _f.write(prompt if prompt is not None else "(None=用工作流内置默认)") + except Exception: # noqa: BLE001 + pass + # 3. 提交 r = cli.post("/prompt", json={"prompt": wf, "client_id": client_id}) r.raise_for_status() diff --git a/hairline/service.py b/hairline/service.py index 6f75237..7a8d112 100644 --- a/hairline/service.py +++ b/hairline/service.py @@ -235,7 +235,8 @@ def generate_hairline_pngs(image_bgr: np.ndarray, gender: str, 三档贴图同名,生发黑模板固定取自 hairline_texture_black/(middle),故生发目标固定 middle 档。 use_mask/prompt:同接口2 的生发参数。 Returns: {"images":[{hairline_type,order,overlays:{middle,high,low}((H,W,4) RGBA 透明层),grown_png}], - "best_center":(x,y)};无人脸 None。best_center 取首个选中发型的 middle 档。 + "best_centers":{"middle":(x,y),"high":(x,y),"low":(x,y)}};无人脸 None。 + best_centers 取首个选中发型三档各自的发际线中点。 """ if gender not in ("male", "female"): raise ValueError(f"gender 必须是 male/female,收到 {gender!r}") @@ -258,7 +259,16 @@ def generate_hairline_pngs(image_bgr: np.ndarray, gender: str, if not use_mask: shared_grown = _grow_from_texture(image_bgr, ctx, None, use_mask=False, prompt=prompt) - images, best_center = [], None + def _center_of(overlay): + """从某档发际线透明叠图取面部中轴处的发际线中点 (x,y),无像素返回 None。""" + ys, xs = np.where(overlay[:, :, 3] > 40) + if not xs.size: + return None + near = np.abs(xs - face_cx) <= max(2, int(w * 0.02)) + col_ys = ys[near] if near.any() else ys[np.argsort(np.abs(xs - face_cx))[:20]] + return (int(round(face_cx)), int(round(float(col_ys.mean())))) + + images, best_centers = [], None for s in hair_styles: # s = 1-indexed 发型序号 key, mid_path = tex_by_level["middle"][s - 1] overlays = {} @@ -270,15 +280,10 @@ def generate_hairline_pngs(image_bgr: np.ndarray, gender: str, _grow_from_texture(image_bgr, ctx, mid_path, use_mask=True, prompt=prompt) images.append({"hairline_type": key, "order": s, "overlays": overlays, "grown_png": grown_png}) - # best_center:首个选中发型的 middle 档发际线中点(面部中轴处的发际线 y) - if best_center is None: - overlay = overlays["middle"] # 复用已渲染的 middle 档透明层 - ys, xs = np.where(overlay[:, :, 3] > 40) - if xs.size: - near = np.abs(xs - face_cx) <= max(2, int(w * 0.02)) - col_ys = ys[near] if near.any() else ys[np.argsort(np.abs(xs - face_cx))[:20]] - best_center = (int(round(face_cx)), int(round(float(col_ys.mean())))) - return {"images": images, "best_center": best_center} + # best_centers:首个选中发型三档(middle/high/low)发际线中点 + if best_centers is None: + best_centers = {lv: _center_of(overlays[lv]) for lv in _TEXTURE_DIRS} + return {"images": images, "best_centers": best_centers} def generate_grow_b(marked_bgr: np.ndarray, use_mask: bool = True, prompt: str = None): diff --git a/static/integration.html b/static/integration.html index 81e8a0a..1fedc39 100644 --- a/static/integration.html +++ b/static/integration.html @@ -236,7 +236,9 @@ console.log(features['四季色彩季型']); // "冷夏型"(中文字段也保
| 字段 | 类型 | 说明 |
|---|---|---|
hairline_images[] | object[] | 选中发型列表,每项含 hairline_type、image_middle_url/image_high_url/image_low_url 三档透明 PNG 叠图(仅曲线,需叠加原图)、grown_image_url 生发图(完整人像,失败为 null)、order |
best_hairline_center_point | object | 首个选中发型 middle 档发际线中心点像素坐标 { x: number, y: number } |
best_hairline_center_point | object \| null | 首个选中发型 middle 档发际线中心点像素坐标 { x: number, y: number } |
high_hairline_center_point | object \| null | 同上,high 档发际线中点(发际线偏高,y 更小) |
low_hairline_center_point | object \| null | 同上,low 档发际线中点(发际线偏低,y 更大) |
face_measure | object \| null | 复用接口1的四庭七眼测量数值(不含标注图)。独立流程,测量失败时为 null,不影响发际线主结果。结构见下表 |
annotated_image_urlface_total_height_cmfour_courtsseven_eyesseven_eyeseye_width_cm/face_width_cm/inter_eye_distance_cm + ratios + eye2~eye6(左脸颊/左眼/两眼间距/右眼/右脸颊,5 段宽度 cm;无 eye1/eye7)landmarks