save code
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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,已移除)的区别
|
||||
|
||||
|
||||
+10
-3
@@ -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": {
|
||||
|
||||
+281
-35
@@ -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)
|
||||
|
||||
@@ -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 类索引
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
+16
-11
@@ -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):
|
||||
|
||||
@@ -236,7 +236,9 @@ console.log(features['四季色彩季型']); // "冷夏型"(中文字段也保
|
||||
<table>
|
||||
<tr><th>字段</th><th>类型</th><th>说明</th></tr>
|
||||
<tr><td><code>hairline_images[]</code></td><td>object[]</td><td>选中发型列表,每项含 <code>hairline_type</code>、<code>image_middle_url</code>/<code>image_high_url</code>/<code>image_low_url</code> 三档<strong>透明 PNG 叠图</strong>(仅曲线,需叠加原图)、<code>grown_image_url</code> 生发图(完整人像,失败为 null)、<code>order</code></td></tr>
|
||||
<tr><td><code>best_hairline_center_point</code></td><td>object</td><td>首个选中发型 middle 档发际线中心点像素坐标 <code>{ x: number, y: number }</code></td></tr>
|
||||
<tr><td><code>best_hairline_center_point</code></td><td>object \| null</td><td>首个选中发型 <strong>middle 档</strong>发际线中心点像素坐标 <code>{ x: number, y: number }</code></td></tr>
|
||||
<tr><td><code>high_hairline_center_point</code></td><td>object \| null</td><td>同上,<strong>high 档</strong>发际线中点(发际线偏高,y 更小)</td></tr>
|
||||
<tr><td><code>low_hairline_center_point</code></td><td>object \| null</td><td>同上,<strong>low 档</strong>发际线中点(发际线偏低,y 更大)</td></tr>
|
||||
<tr><td><code>face_measure</code></td><td>object \| null</td><td>复用<a href="#if1">接口1</a>的<strong>四庭七眼测量数值</strong>(不含标注图)。独立流程,测量失败时为 <code>null</code>,不影响发际线主结果。结构见下表</td></tr>
|
||||
</table>
|
||||
|
||||
@@ -278,7 +280,7 @@ console.log(features['四季色彩季型']); // "冷夏型"(中文字段也保
|
||||
<tr><td><code>annotated_image_url</code></td><td>string</td><td>标注 PNG URL(仅标注图层,透明底)</td></tr>
|
||||
<tr><td><code>face_total_height_cm</code></td><td>number</td><td>面部总高度(cm)= 三庭之和</td></tr>
|
||||
<tr><td><code>four_courts</code></td><td>object</td><td>三庭:upper/middle/lower,各含 _cm 和 ratios(<strong>无 top_court</strong>)</td></tr>
|
||||
<tr><td><code>seven_eyes</code></td><td>object</td><td>七眼:eye_width/face_width/inter_eye_distance</td></tr>
|
||||
<tr><td><code>seven_eyes</code></td><td>object</td><td>七眼:<code>eye_width_cm</code>/<code>face_width_cm</code>/<code>inter_eye_distance_cm</code> + <code>ratios</code> + <strong><code>eye2</code>~<code>eye6</code></strong>(左脸颊/左眼/两眼间距/右眼/右脸颊,5 段宽度 cm;<strong>无 eye1/eye7</strong>)</td></tr>
|
||||
<tr><td><code>landmarks</code></td><td>object</td><td>4 个关键点:hairline/brow_center/nose_bottom/chin_tip(<strong>无 hair_top</strong>)</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
<h1>接口11 调试页 <span style="font-size:13px;color:#888">(带前后端日志)</span></h1>
|
||||
<div class="subtitle">
|
||||
独立调试页,每一步都记录日志。提交后展开"前端日志"和"后端日志",点按钮可下载。<br>
|
||||
重点排查:遮罩是否走 pushed、①-f/①-g 是否有图、最终遮罩是否正确。遮罩固定 pushed、融合固定 multiband。
|
||||
重点排查:遮罩是否走 pushed、①-f/①-g 是否有图、最终遮罩是否正确。遮罩固定 pushed,融合默认 multiband(可调,含两段式)。
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
@@ -85,10 +85,6 @@
|
||||
<option value="bisenet">bisenet</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="pf">
|
||||
<label>erode_cm <span class="desc">baseline参考内缩(cm)</span></label>
|
||||
<div class="row"><input type="number" id="erodeCm" min="0" max="5" step="0.1" value="0.6"></div>
|
||||
</div>
|
||||
<div class="pf">
|
||||
<label>hairline_push_cm <span class="desc">发际线外推(cm)</span></label>
|
||||
<div class="row"><input type="number" id="hairlinePushCm" min="0" max="3" step="0.1" value="1.0"></div>
|
||||
@@ -100,6 +96,10 @@
|
||||
<option value="contour">contour(形态学轮廓)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="pf">
|
||||
<label>is_hr <span class="desc">高清模式(换发型输出1152×1536)</span></label>
|
||||
<div class="row"><input type="checkbox" id="isHr" style="width:18px;height:18px"><span class="desc">仅 swaphair 后端;关=576×768</span></div>
|
||||
</div>
|
||||
<div class="pf">
|
||||
<label>mb_levels <span class="desc">多频段金字塔层数</span></label>
|
||||
<div class="row"><input type="number" id="mbLevels" min="2" max="6" step="1" value="5"></div>
|
||||
@@ -108,8 +108,59 @@
|
||||
<label>edge_erode_px <span class="desc">贴图前遮罩内缩(px)</span></label>
|
||||
<div class="row"><input type="number" id="edgeErodePx" min="0" max="40" step="1" value="3"></div>
|
||||
</div>
|
||||
<div class="pf">
|
||||
<label>blend_method <span class="desc">接缝融合方法</span></label>
|
||||
<select id="blendMethod">
|
||||
<option value="multiband">multiband(多频段金字塔,默认)</option>
|
||||
<option value="two_stage">two_stage(泊松→多频段,大色差)</option>
|
||||
<option value="seamless">seamless(泊松无缝克隆)</option>
|
||||
<option value="feather">feather(高斯羽化)</option>
|
||||
<option value="alpha_gradient">alpha_gradient(距离变换)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="pf">
|
||||
<label>color_match <span class="desc">融合前颜色迁移(消除色差)</span></label>
|
||||
<div class="row"><input type="checkbox" id="colorMatch" checked style="width:18px;height:18px"><span class="desc" id="cmNote">multiband/feather 生效;seamless/two_stage 自带调色</span></div>
|
||||
</div>
|
||||
<div class="pf">
|
||||
<label>color_match_strength <span class="desc">颜色迁移强度(0~1)</span></label>
|
||||
<div class="row"><input type="number" id="cmStrength" min="0" max="1" step="0.05" value="1.0"></div>
|
||||
</div>
|
||||
<div class="pf">
|
||||
<label>mb_feather_px <span class="desc">最细层掩码羽化(px)</span></label>
|
||||
<div class="row"><input type="number" id="mbFeatherPx" min="0" max="5" step="1" value="1"></div>
|
||||
</div>
|
||||
<div class="pf">
|
||||
<label>transition_band_px <span class="desc">过渡带边距(-1=自动)</span></label>
|
||||
<div class="row"><input type="number" id="transitionBandPx" min="-1" max="128" step="1" value="-1"></div>
|
||||
</div>
|
||||
<div class="pf">
|
||||
<label>redraw <span class="desc">发际线带重绘开关</span></label>
|
||||
<div class="row"><input type="checkbox" id="redraw" style="width:18px;height:18px"><span class="desc">开:发际线带(外推↔内推)重绘,final输入,swapHair/Flux-2两路对比</span></div>
|
||||
</div>
|
||||
<div class="pf">
|
||||
<label>inpainting_fill <span class="desc">重绘填充(治染绿)</span></label>
|
||||
<select id="inpaintingFill">
|
||||
<option value="1">1=填充噪声(默认/原始)</option>
|
||||
<option value="0">0=保留原图(治染绿)</option>
|
||||
<option value="2">2=纯色填充</option>
|
||||
<option value="3">3=潜变量噪声</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="pf">
|
||||
<label>mask_blur <span class="desc">重绘遮罩边缘模糊(px)</span></label>
|
||||
<div class="row"><input type="number" id="maskBlur" min="0" max="64" step="1" value="11"></div>
|
||||
</div>
|
||||
<div class="pf">
|
||||
<label>mask_dilate_scale <span class="desc">重绘遮罩膨胀缩放</span></label>
|
||||
<div class="row"><input type="number" id="maskDilateScale" min="0" max="4" step="0.1" value="1.0"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size:12px;color:#888;margin-top:8px">遮罩算法固定 pushed、融合固定 multiband,无需选择。</div>
|
||||
<div class="pf" style="margin-top:14px">
|
||||
<label>comfyui_prompt <span class="desc">redraw C路(Flux-2)提示词,留空=默认「补充遮罩区域的头发,加一点美颜」</span></label>
|
||||
<textarea id="comfyuiPrompt" rows="2" style="width:100%;padding:8px;border:1px solid #ddd;border-radius:8px;font-size:13px;font-family:inherit;resize:vertical" placeholder="留空用默认提示词"></textarea>
|
||||
</div>
|
||||
<div style="font-size:12px;color:#888;margin-top:8px">遮罩算法固定 pushed。融合默认 multiband,可在上方 blend_method 切换对比。勾选 redraw 开启发际线带重绘(swapHair/Flux-2 两路对比)。</div>
|
||||
</div>
|
||||
|
||||
<div class="status hidden" id="statusBar"></div>
|
||||
@@ -176,6 +227,9 @@ const STEPS = [
|
||||
{ key: 'hard_paste', title: '③ 严格贴回', sub: '遮罩内=生成,外=原图' },
|
||||
{ key: 'alpha', title: '④ 融合权重', sub: '白=用生成图' },
|
||||
{ key: 'final', title: '④ 接缝融合(最终)', sub: '最终输出' },
|
||||
{ key: 'redraw_band_overlay', title: '⑤-① 发际线重绘带', sub: '紫=外推↔内推之间的带(以内轮廓为中心,两侧各push_cm,仅redraw)' },
|
||||
{ key: 'redraw_a', title: '⑤-② 带重绘 swapHair路', sub: 'final+带重绘(swapHair)→final融合(仅redraw)' },
|
||||
{ key: 'redraw_c', title: '⑤-③ 带重绘 Flux-2路', sub: 'final+带重绘(Flux-2,保色不染绿)→final融合(仅redraw)' },
|
||||
];
|
||||
|
||||
function $(id) { return document.getElementById(id); }
|
||||
@@ -194,7 +248,7 @@ function stepCard(title, sub, src) {
|
||||
}
|
||||
|
||||
function renderResult(d) {
|
||||
flog('renderResult 开始(固定 pushed + multiband)', 'info');
|
||||
flog('renderResult 开始 (mask=pushed, blend=' + (d.blend_method || '?') + ')', 'info');
|
||||
const s = d.steps || {};
|
||||
$('finalInput').src = pick(s, 'input') || '';
|
||||
$('finalInput').onclick = function(){ zoom(this.src); };
|
||||
@@ -202,12 +256,22 @@ function renderResult(d) {
|
||||
$('finalOut').onclick = function(){ zoom(this.src); };
|
||||
const grid = $('stepsGrid'); grid.innerHTML = '';
|
||||
// 固定 pushed:只展示 pushed 相关步骤(不展示 top_fill/closed)
|
||||
// redraw 步骤(redraw_band/redraw_a/redraw_c)只在开启 redraw 且有图时才渲染
|
||||
const showKeys = new Set(['baseline_overlay','upper_overlay','hair_seg_overlay',
|
||||
'hairline_overlay','pushed_overlay','mask_overlay','mask','swap_raw','hard_paste','alpha','final']);
|
||||
$('stepsInfo').textContent = '(固定 pushed + multiband)';
|
||||
'hairline_overlay','pushed_overlay','mask_overlay','mask','swap_raw','hard_paste','alpha','final',
|
||||
'redraw_band_overlay','redraw_a','redraw_c']);
|
||||
const redrawKeys = new Set(['redraw_band_overlay','redraw_a','redraw_c']);
|
||||
$('stepsInfo').textContent = '(mask=pushed, blend=' + (d.blend_method || '?') + ')';
|
||||
if (d.redraw && d.redraw.enabled) {
|
||||
flog('发际线带重绘已开启 band_pixels=' + d.redraw.band_pixels, 'info');
|
||||
} else if (d.redraw && d.redraw.error) {
|
||||
flog('发际线带重绘失败: ' + d.redraw.error, 'warn');
|
||||
}
|
||||
STEPS.filter(st => showKeys.has(st.key)).forEach(st => {
|
||||
const src = pick(s, st.key);
|
||||
const hasImg = src && src.length > 50;
|
||||
// redraw 步骤无图(未开启或失败)时跳过,不占位
|
||||
if (redrawKeys.has(st.key) && !hasImg) return;
|
||||
flog(' 渲染 ' + st.key + ': ' + (hasImg ? '有图(' + src.length + '字符)' : '无图'), hasImg ? 'info' : 'warn');
|
||||
grid.appendChild(stepCard(st.title, st.sub, src));
|
||||
});
|
||||
@@ -223,11 +287,22 @@ async function submitTest() {
|
||||
flog('前端读取 hairline_edge=' + ($('hairlineEdge').value || '(默认)'), 'info');
|
||||
flog('前端读取 hairline_id=' + $('hairlineId').value, 'info');
|
||||
flog('前端读取 seg_model=' + $('segModel').value, 'info');
|
||||
flog('前端读取 is_hr=' + $('isHr').checked, 'info');
|
||||
flog('前端读取 mb_levels=' + ($('mbLevels').value || '(默认)'), 'info');
|
||||
flog('前端读取 blend_method=' + $('blendMethod').value, 'info');
|
||||
flog('前端读取 color_match=' + $('colorMatch').checked, 'info');
|
||||
flog('前端读取 color_match_strength=' + ($('cmStrength').value || '(默认)'), 'info');
|
||||
flog('前端读取 mb_feather_px=' + ($('mbFeatherPx').value || '(默认)'), 'info');
|
||||
flog('前端读取 transition_band_px=' + ($('transitionBandPx').value || '(默认)'), 'info');
|
||||
flog('前端读取 redraw=' + $('redraw').checked, 'info');
|
||||
flog('前端读取 inpainting_fill=' + $('inpaintingFill').value, 'info');
|
||||
flog('前端读取 mask_blur=' + ($('maskBlur').value || '(默认)'), 'info');
|
||||
flog('前端读取 mask_dilate_scale=' + ($('maskDilateScale').value || '(默认)'), 'info');
|
||||
flog('前端读取 comfyui_prompt=' + ($('comfyuiPrompt').value.trim() || '(默认)'), 'info');
|
||||
|
||||
const btn = $('submitBtn');
|
||||
btn.disabled = true; btn.textContent = '⏳ 请求中...';
|
||||
setStatus('正在请求(固定 pushed + multiband)...', 'info');
|
||||
setStatus('正在请求 (mask=pushed, blend=' + $('blendMethod').value + ')...', 'info');
|
||||
$('resultsArea').classList.remove('hidden');
|
||||
|
||||
const form = new FormData();
|
||||
@@ -235,15 +310,25 @@ async function submitTest() {
|
||||
form.append('hairline_id', $('hairlineId').value);
|
||||
form.append('gen_backend', 'swaphair');
|
||||
form.append('hairgrow_strength', '0.75');
|
||||
form.append('is_hr', 'false');
|
||||
form.append('is_hr', $('isHr').checked ? 'true' : 'false');
|
||||
form.append('seg_model', $('segModel').value);
|
||||
form.append('erode_cm', $('erodeCm').value || '0.6');
|
||||
form.append('swap_mode', 'ext_mask');
|
||||
form.append('edge_erode_px', $('edgeErodePx').value || '3');
|
||||
form.append('denoising_strength', '0.6');
|
||||
form.append('mb_levels', $('mbLevels').value || '5');
|
||||
form.append('hairline_push_cm', $('hairlinePushCm').value || '1.0');
|
||||
form.append('hairline_edge', $('hairlineEdge').value);
|
||||
form.append('blend_method', $('blendMethod').value);
|
||||
form.append('color_match', $('colorMatch').checked ? 'true' : 'false');
|
||||
form.append('color_match_strength', $('cmStrength').value || '1.0');
|
||||
form.append('mb_feather_px', $('mbFeatherPx').value || '1');
|
||||
form.append('transition_band_px', $('transitionBandPx').value || '-1');
|
||||
form.append('redraw', $('redraw').checked ? 'true' : 'false');
|
||||
form.append('inpainting_fill', $('inpaintingFill').value || '1');
|
||||
form.append('mask_blur', $('maskBlur').value || '11');
|
||||
form.append('mask_dilate_scale', $('maskDilateScale').value || '1.0');
|
||||
const cp = $('comfyuiPrompt').value.trim();
|
||||
if (cp) form.append('comfyui_prompt', cp);
|
||||
|
||||
// 记录发出去的 form 字段
|
||||
const sentFields = {};
|
||||
@@ -266,6 +351,8 @@ async function submitTest() {
|
||||
const d = json.data;
|
||||
flog('后端返回 mask_type=' + d.mask_type + ' blend=' + d.blend_method + ' mask_pixels=' + d.mask_pixels, 'info');
|
||||
flog('后端返回 hairline_push_cm=' + d.hairline_push_cm + ' hairline_edge=' + d.hairline_edge, 'info');
|
||||
flog('后端返回 color_match=' + d.color_match + ' cm_strength=' + d.color_match_strength + ' mb_feather_px=' + d.mb_feather_px + ' transition_band_px=' + d.transition_band_px, 'info');
|
||||
flog('后端返回 inpainting_fill=' + d.inpainting_fill + ' mask_blur=' + d.mask_blur + ' mask_dilate_scale=' + d.mask_dilate_scale, 'info');
|
||||
flog('后端 _rid=' + d._rid, 'info');
|
||||
// 详细记录 steps 每个字段长度
|
||||
const s = d.steps || {};
|
||||
@@ -316,7 +403,7 @@ async function downloadBackendLog() {
|
||||
$('imageFile').addEventListener('change', function(){ if(this.files.length) flog('选择图片: ' + this.files[0].name, 'info'); });
|
||||
|
||||
flog('调试页加载完成', 'info');
|
||||
flog('遮罩固定 pushed、融合固定 multiband,选好图片直接提交', 'info');
|
||||
flog('遮罩固定 pushed,融合默认 multiband(可调,含两段式 two_stage),选好图片直接提交', 'info');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -132,8 +132,12 @@
|
||||
<div class="card-header"><span>🎯 所有发型结果(三档 + 生发图)</span><span style="font-weight:400;font-size:12px;color:#9ca3af" id="resultCount">共 0 个发型</span></div>
|
||||
<div class="card-body">
|
||||
<div class="coord-box" style="margin-top:0;margin-bottom:16px">
|
||||
<div class="label">📍 最佳发际线中心点(best_hairline_center_point,首个发型 middle 档)— 原图像素坐标</div>
|
||||
<div class="value" id="centerPoint">—</div>
|
||||
<div class="label">📍 发际线中心点(首个发型三档)— 原图像素坐标</div>
|
||||
<div class="value">
|
||||
middle: <span id="centerPoint">—</span>
|
||||
| high: <span id="centerHigh">—</span>
|
||||
| low: <span id="centerLow">—</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="resultsGrid"></div>
|
||||
</div>
|
||||
@@ -211,7 +215,10 @@ async function submitTest() {
|
||||
if (json.code === 0) {
|
||||
_images = json.data.hairline_images || [];
|
||||
_center = json.data.best_hairline_center_point;
|
||||
$('centerPoint').textContent = _center ? '(' + _center.x + ', ' + _center.y + ')' : '—';
|
||||
const _fmtPt = p => p ? '(' + p.x + ', ' + p.y + ')' : '—';
|
||||
$('centerPoint').textContent = _fmtPt(json.data.best_hairline_center_point);
|
||||
$('centerHigh').textContent = _fmtPt(json.data.high_hairline_center_point);
|
||||
$('centerLow').textContent = _fmtPt(json.data.low_hairline_center_point);
|
||||
$('resultCount').textContent = '共 ' + _images.length + ' 个发型';
|
||||
setStatus('✅ ' + _images.length + ' 个发型 × 三档 (' + _elapsed + 's)', 'success');
|
||||
renderGrid();
|
||||
|
||||
Reference in New Issue
Block a user