Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38161d1b50 | ||
|
|
ce95a508c1 | ||
|
|
94ad95850e | ||
|
|
4c69bb4623 |
@@ -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": 690815303279000
|
||||||
|
},
|
||||||
|
"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-1780841075435.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_nljpo_00001_.png&type=temp&subfolder=&rand=0.7003773747423834"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "B",
|
||||||
|
"selected": true,
|
||||||
|
"url": "/api/view?filename=rgthree.compare._temp_nljpo_00002_.png&type=temp&subfolder=&rand=0.10574778041280719"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"image_a": [
|
||||||
|
"62",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"image_b": [
|
||||||
|
"26",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "Image Comparer (rgthree)",
|
||||||
|
"_meta": {
|
||||||
|
"title": "图像对比"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"60": {
|
||||||
|
"inputs": {
|
||||||
|
"text": "严格保留原图人物五官、面部肤色、神态、服饰、背景、原图光影与整体色调,画面除划线区域外全部细节保持原样不变;先清除画面内所有黑色标注划线,仅在原划线划定范围内生成植发术后 3 个月长发效果,新生头发生长边界刚好止于原划线位置,新发沿原划线轮廓边缘和外部原生发丝交错融合、自然无缝衔接,无生硬分界线;发色和自身原生黑发保持统一,发丝细腻写实、带有真实毛发纹理与自然光泽,发量均匀,符合植发 3 个月刚长出的轻微稀疏生长状态,毛发走向遵从头部原生头发生长规律,头发光影明暗和原图环境统一,整体写实自然,无贴片假发质感。"
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -504,17 +504,22 @@ async def hair_grow(
|
|||||||
return err(1002, "人像分辨率过低")
|
return err(1002, "人像分辨率过低")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from hairline.service import generate_previews
|
from fastapi.concurrency import run_in_threadpool
|
||||||
|
from hairline.service import generate_grow_results
|
||||||
|
|
||||||
previews = generate_previews(image, gender) # 无人脸 → None
|
# 预览 + 生发(ComfyUI) 都是阻塞且较慢,放线程池避免卡住事件循环
|
||||||
if previews is None:
|
items = await run_in_threadpool(generate_grow_results, image, gender)
|
||||||
|
if items is None:
|
||||||
return err(1001, "无法识别人像")
|
return err(1001, "无法识别人像")
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
for p in previews:
|
for p in items:
|
||||||
ok_enc, png = cv2.imencode(".png", p["image_bgr"])
|
_ok, png = cv2.imencode(".png", p["image_bgr"])
|
||||||
|
grown_b64 = (base64.b64encode(p["grown_png"]).decode()
|
||||||
|
if p["grown_png"] else None)
|
||||||
results.append({
|
results.append({
|
||||||
"image_base64": base64.b64encode(png.tobytes()).decode(),
|
"image_base64": base64.b64encode(png.tobytes()).decode(),
|
||||||
|
"grown_image_base64": grown_b64,
|
||||||
"hairline_type": p["hairline_type"],
|
"hairline_type": p["hairline_type"],
|
||||||
"order": p["order"],
|
"order": p["order"],
|
||||||
})
|
})
|
||||||
@@ -554,9 +559,9 @@ async def hair_grow(
|
|||||||
"message": "success",
|
"message": "success",
|
||||||
"request_id": "mock-request-id",
|
"request_id": "mock-request-id",
|
||||||
"data": {
|
"data": {
|
||||||
"best_hairline_image_url": SAMPLE_IMAGE_URL,
|
"best_hairline_image_base64": "iVBORw0KGgo...(原图)",
|
||||||
"hair_growth_image_url": SAMPLE_IMAGE_URL,
|
"hair_growth_image_base64": "iVBORw0KGgo...(生发图)",
|
||||||
"hairline_type": "花瓣形",
|
"hairline_type": "custom",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -580,12 +585,46 @@ async def hair_grow_b(
|
|||||||
original_image_url: Optional[str] = Form(default=None, description="原始用户照片 URL"),
|
original_image_url: Optional[str] = Form(default=None, description="原始用户照片 URL"),
|
||||||
original_image_base64: Optional[str] = Form(default=None, description="原始用户照片 base64"),
|
original_image_base64: Optional[str] = Form(default=None, description="原始用户照片 base64"),
|
||||||
):
|
):
|
||||||
data = {
|
# 1. 两组图各三选一取图
|
||||||
"best_hairline_image_url": SAMPLE_IMAGE_URL,
|
marked_raw, e = await resolve_image_bytes(marked_image_file, marked_image_url, marked_image_base64)
|
||||||
"hair_growth_image_url": SAMPLE_IMAGE_URL,
|
if e is not None:
|
||||||
"hairline_type": "花瓣形",
|
return e
|
||||||
}
|
orig_raw, e = await resolve_image_bytes(original_image_file, original_image_url, original_image_base64)
|
||||||
return ok(data)
|
if e is not None:
|
||||||
|
return e
|
||||||
|
if len(marked_raw) > MAX_FILE_BYTES or len(orig_raw) > MAX_FILE_BYTES:
|
||||||
|
return err(1006, "文件超出 1 MB 限制")
|
||||||
|
|
||||||
|
marked = cv2.imdecode(np.frombuffer(marked_raw, np.uint8), cv2.IMREAD_COLOR)
|
||||||
|
original = cv2.imdecode(np.frombuffer(orig_raw, np.uint8), cv2.IMREAD_COLOR)
|
||||||
|
if marked is None or original is None:
|
||||||
|
return err(1008, "图片格式不支持(仅 JPG / PNG)")
|
||||||
|
|
||||||
|
h, w = marked.shape[:2]
|
||||||
|
short_side, long_side = min(w, h), max(w, h)
|
||||||
|
if short_side < MIN_SHORT_SIDE or long_side < MIN_LONG_SIDE:
|
||||||
|
return err(1002, "人像分辨率过低")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from fastapi.concurrency import run_in_threadpool
|
||||||
|
from hairline.service import generate_grow_b
|
||||||
|
|
||||||
|
res = await run_in_threadpool(generate_grow_b, marked, original)
|
||||||
|
if res["status"] == "no_face":
|
||||||
|
return err(1001, "无法识别人像")
|
||||||
|
if res["status"] == "no_line":
|
||||||
|
return err(1001, "未检测到发际线划线,请确认划线图额头有清晰的手绘发际线")
|
||||||
|
|
||||||
|
grown_b64 = base64.b64encode(res["grown_png"]).decode() if res["grown_png"] else None
|
||||||
|
data = {
|
||||||
|
"best_hairline_image_base64": base64.b64encode(orig_raw).decode(), # 原图原样
|
||||||
|
"hair_growth_image_base64": grown_b64,
|
||||||
|
"hairline_type": "custom",
|
||||||
|
}
|
||||||
|
return ok(data)
|
||||||
|
except Exception as ex: # noqa: BLE001
|
||||||
|
logger.exception("接口3 处理异常")
|
||||||
|
return err(1007, f"处理失败:{ex}")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -682,10 +721,15 @@ async def face_features(
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
**入参**:新增必填 `gender`(`male`/`female`),决定返回的发际线集合(female 5 / male 4)。
|
||||||
|
|
||||||
**返回说明**:
|
**返回说明**:
|
||||||
|
|
||||||
- `hairline_images`:发际线 PNG 列表,数量 N 不固定,已按合适度**从高到低排序**(`order=1` 最合适)
|
- `hairline_images`:发际线叠加图列表(发际线曲线叠加在用户照片上,同接口2预览),
|
||||||
- `best_hairline_center_point`:最合适发际线的面部中间点坐标,以**原图像素**为基准(左上角为原点,x 向右,y 向下)
|
数量 = 该性别的发际线类型数,本期按贴图顺序 `order=1..N`(暂不计算合适度)。
|
||||||
|
worker 返回 `image_base64`,网关落盘后改写为 `image_url`。
|
||||||
|
- `best_hairline_center_point`:最佳(`order=1`)发际线曲线的**面部中间点**坐标,
|
||||||
|
以**原图像素**为基准(左上角为原点,x 向右,y 向下)。
|
||||||
""",
|
""",
|
||||||
responses={
|
responses={
|
||||||
200: {
|
200: {
|
||||||
@@ -698,8 +742,8 @@ async def face_features(
|
|||||||
"request_id": "mock-request-id",
|
"request_id": "mock-request-id",
|
||||||
"data": {
|
"data": {
|
||||||
"hairline_images": [
|
"hairline_images": [
|
||||||
{"image_url": SAMPLE_IMAGE_URL, "order": 1},
|
{"image_base64": "iVBORw0KGgo...", "order": 1},
|
||||||
{"image_url": SAMPLE_IMAGE_URL, "order": 2},
|
{"image_base64": "iVBORw0KGgo...", "order": 2},
|
||||||
],
|
],
|
||||||
"best_hairline_center_point": {"x": 540, "y": 430},
|
"best_hairline_center_point": {"x": 540, "y": 430},
|
||||||
},
|
},
|
||||||
@@ -721,15 +765,50 @@ async def hairline_generate(
|
|||||||
image_file: Optional[UploadFile] = File(default=None, description="上传图片文件(JPG/PNG,≤ 1 MB)"),
|
image_file: Optional[UploadFile] = File(default=None, description="上传图片文件(JPG/PNG,≤ 1 MB)"),
|
||||||
image_url: Optional[str] = Form(default=None, description="图片 URL"),
|
image_url: Optional[str] = Form(default=None, description="图片 URL"),
|
||||||
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"),
|
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"),
|
||||||
|
gender: Optional[str] = Form(default=None, description="性别 male/female(必填)"),
|
||||||
):
|
):
|
||||||
data = {
|
if gender not in ("male", "female"):
|
||||||
"hairline_images": [
|
return err(1004, "gender 必填且只能为 male / female")
|
||||||
{"image_url": SAMPLE_IMAGE_URL, "order": 1},
|
|
||||||
{"image_url": SAMPLE_IMAGE_URL, "order": 2},
|
raw, e = await resolve_image_bytes(image_file, image_url, image_base64)
|
||||||
],
|
if e is not None:
|
||||||
"best_hairline_center_point": {"x": 540, "y": 430},
|
return e
|
||||||
}
|
if len(raw) > MAX_FILE_BYTES:
|
||||||
return ok(data)
|
return err(1006, "文件超出 1 MB 限制")
|
||||||
|
|
||||||
|
image = cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR)
|
||||||
|
if image is None:
|
||||||
|
return err(1008, "图片格式不支持(仅 JPG / PNG)")
|
||||||
|
|
||||||
|
h, w = image.shape[:2]
|
||||||
|
short_side, long_side = min(w, h), max(w, h)
|
||||||
|
if short_side < MIN_SHORT_SIDE or long_side < MIN_LONG_SIDE:
|
||||||
|
return err(1002, "人像分辨率过低")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from fastapi.concurrency import run_in_threadpool
|
||||||
|
from hairline.service import generate_hairline_pngs
|
||||||
|
|
||||||
|
res = await run_in_threadpool(generate_hairline_pngs, image, gender)
|
||||||
|
if res is None:
|
||||||
|
return err(1001, "无法识别人像")
|
||||||
|
|
||||||
|
hairline_images = []
|
||||||
|
for it in res["images"]:
|
||||||
|
_ok, png = cv2.imencode(".png", it["image_bgr"])
|
||||||
|
hairline_images.append({
|
||||||
|
"image_base64": base64.b64encode(png.tobytes()).decode(),
|
||||||
|
"order": it["order"],
|
||||||
|
})
|
||||||
|
c = res["best_center"]
|
||||||
|
data = {
|
||||||
|
"hairline_images": hairline_images,
|
||||||
|
"best_hairline_center_point": ({"x": c[0], "y": c[1]} if c else None),
|
||||||
|
}
|
||||||
|
return ok(data)
|
||||||
|
except Exception as ex: # noqa: BLE001
|
||||||
|
logger.exception("接口5 处理异常")
|
||||||
|
return err(1007, f"处理失败:{ex}")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -242,5 +242,114 @@ transformers>=4.40 # SegFormer 人脸分割
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
> **文档版本**: v1.0 | **创建日期**: 2026-06-14 | 算法来源: head3d(502 点 mesh + UV)| 运行位置: worker(GPU)
|
## 10. 第二步:生发图生成(ComfyUI + Flux inpaint)★ 新增
|
||||||
> **本期产出**: 发际线曲线叠加预览图(非最终生发图)
|
|
||||||
|
> 在「发际线预览」基础上,**新增真实生发后图片**:把发际线划线 + 遮罩送入本机
|
||||||
|
> ComfyUI(Flux-2 Klein 9b,端口 **8182**)跑 `add_hair.json` 工作流,得到「植发 3
|
||||||
|
> 个月」效果图。**worker 不跑 Flux**,只做图像准备 + 调 ComfyUI HTTP API + 取回结果。
|
||||||
|
|
||||||
|
### 10.1 需求与决策(已与需求方确认)
|
||||||
|
|
||||||
|
| 项 | 决策 |
|
||||||
|
|----|------|
|
||||||
|
| 生成数量 | **一次请求生成该性别全部 N 种**(female 5 / male 4),与预览一一对应 |
|
||||||
|
| 遮罩区域 | **头发区域 ∪ 新发际线以下** —— SegFormer 头部(头发)区域,下边界拓到新发际线曲线 |
|
||||||
|
| 返回方式 | **同步阻塞**到 ComfyUI 出图再返回(N 张串行,单请求耗时可达数分钟,网关需调大超时) |
|
||||||
|
| ComfyUI 接入 | 标准 HTTP API(`/upload/image` + `/prompt` + `/history` + `/view`),8182 无鉴权,自定义节点已装齐,`noise_seed` 每次随机 |
|
||||||
|
|
||||||
|
### 10.2 `add_hair.json` 工作流解读
|
||||||
|
|
||||||
|
Flux-2 Klein 9b 的参考式局部重绘(denoise=1 + ReferenceLatent):
|
||||||
|
- **节点 26 `LoadImage`** 是唯一外部输入,同时给出 **图像**([0])和 **遮罩**([1],从 PNG 的
|
||||||
|
alpha 通道取,ComfyUI 约定 `mask = 1 − alpha`,即 **alpha 透明处 = 要重绘的区域**)。
|
||||||
|
- 提示词(节点 60):保留原图一切,**先清除画面内所有黑色标注划线**,仅在划线范围内生成
|
||||||
|
「植发 3 个月」头发,发际线边界刚好止于划线处,与原生发自然衔接。
|
||||||
|
- 节点 32/37/39/44 做遮罩填洞、缩放到 1024、ImageAndMaskPreview 组装 → VAEEncode 参考。
|
||||||
|
- 节点 10 VAEDecode → 节点 62 ColorMatch(与原图调色一致)→ 节点 17 SaveImage = 生发图。
|
||||||
|
|
||||||
|
> **关键**:worker 要做的就是**程序化复现「手绘 painted-masked」的输入**——给节点 26 一张
|
||||||
|
> RGBA:**RGB = 画了黑色发际线划线的照片,alpha = 遮罩(重绘区透明)**。工作流其余不动。
|
||||||
|
|
||||||
|
### 10.3 遮罩算法(参考 `/home/xsl/headmark`,用黑贴图简化)
|
||||||
|
|
||||||
|
headmark(发际线蒙板工具)的 5 步法:① MediaPipe 取**额头上半区域** → ② **整个头部分割** →
|
||||||
|
③ 两者**交集** = ROI → ④ 在 ROI 内**找发际线**(手绘划线)→ ⑤ 发际线 + 头型**围成闭合区域**填充 = 遮罩。
|
||||||
|
|
||||||
|
> **我们的简化**:发际线不是手绘、需要检测的;而是用 `hairline_texture_black/` 的黑曲线
|
||||||
|
> **程序化渲染**出来——位置已知,**省掉 headmark 第 4 步的检测**,直接拿渲染出的曲线当边界。
|
||||||
|
|
||||||
|
```
|
||||||
|
已有:502 点、SegFormer parse_map
|
||||||
|
build_inpaint_mask(photo, parse_map, landmarks, hairline_texture_black/t):
|
||||||
|
[1] upper_region = MediaPipe 额头边界关键点
|
||||||
|
[21,68,104,69,108,151,337,299,333,298,251] 连线,向上+两侧补到图像边缘,填充
|
||||||
|
(= headmark step1:发际线以上的"上部区域")
|
||||||
|
[2] head_mask = SegFormer 头部轮廓(hair ∪ skin ∪ 其余面部类,排除 bg/neck/cloth)
|
||||||
|
(= headmark step2;headmark 用 head-segmentation 包/Selfie,本项目复用已加载的 SegFormer)
|
||||||
|
[3] roi = upper_region ∩ head_mask (= headmark step3)
|
||||||
|
[4] 划线图 marked + curve_mask = render(photo, 502点, 黑贴图 t) # 烧黑线 + 得到曲线像素
|
||||||
|
[5] mask = roi 中"在发际线曲线以上(更小 y)"的部分 → 闭运算去洞 + 取最大连通域填充 + 轻羽化
|
||||||
|
(= headmark step5:发际线曲线 + ROI 上边界围成的闭合区域)
|
||||||
|
return marked(划线图), mask
|
||||||
|
```
|
||||||
|
|
||||||
|
for 每种发际线贴图 t(该性别全部):
|
||||||
|
- `marked, mask = build_inpaint_mask(...)`
|
||||||
|
- `comfy_input = RGBA(rgb=marked, alpha=255*(1−mask))` # **透明=重绘区**,对齐 ComfyUI `mask=1−alpha`
|
||||||
|
- `grown = comfyui_run(comfy_input)`(§10.4)
|
||||||
|
- `results[t] = { preview(白线预览,已有), grown(生发图,新增) }`
|
||||||
|
|
||||||
|
- `hairline_texture_black/`:与 `hairline_texture/` 同 9 张曲线,但**黑色**,烧划线 + 当遮罩下边界。
|
||||||
|
- **头部分割来源**:先复用已加载的 SegFormer(零新增依赖);若头型轮廓不够干净,可改用
|
||||||
|
headmark 同款 `head-segmentation` 包(子进程,避免与 MediaPipe GPU 冲突)。
|
||||||
|
- 遮罩边界精度 **M5 必须 dump 可视化核验**(划线图 / ROI / 最终 mask 三张叠图)。
|
||||||
|
|
||||||
|
### 10.4 ComfyUI 客户端(`hairline/comfyui.py`,新增)
|
||||||
|
|
||||||
|
```
|
||||||
|
COMFYUI_URL = env COMFYUI_URL (默认 http://127.0.0.1:8182)
|
||||||
|
WORKFLOW = add_hair.json(启动时加载一次)
|
||||||
|
run(comfy_input_png_bytes):
|
||||||
|
1. POST /upload/image (multipart) → {name, subfolder, type:"input"}
|
||||||
|
2. wf = deepcopy(WORKFLOW); wf["26"]["inputs"]["image"] = name
|
||||||
|
wf["6"]["inputs"]["noise_seed"] = 随机
|
||||||
|
3. POST /prompt {prompt: wf, client_id} → prompt_id
|
||||||
|
4. 轮询 GET /history/{prompt_id} 直到完成(带超时)
|
||||||
|
5. node "17".images[0] → GET /view?filename&subfolder&type=output → PNG bytes
|
||||||
|
返回 PNG bytes
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.5 接口契约变更(同步更新 `接口文档.md`)
|
||||||
|
|
||||||
|
`/api/v1/hair/grow` 的 `results[]` 每项**新增生发图字段**(worker 返回 base64,网关落盘改 URL):
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `image_base64` | (已有)发际线**预览图**(白线叠加) |
|
||||||
|
| `grown_image_base64` | (新增)**生发后图片**(ComfyUI 出图) |
|
||||||
|
| `hairline_type` / `order` | 同前 |
|
||||||
|
|
||||||
|
> ⚠️ **同步 + N 张 Flux** → 单请求很慢。网关/前端超时要放大;worker 自身并发=1。
|
||||||
|
> 失败处理:某张 ComfyUI 失败时该项 `grown_image_base64` 置空并标记,不整请求失败(待定,实现期确认)。
|
||||||
|
|
||||||
|
### 10.6 开发步骤(M5+)
|
||||||
|
|
||||||
|
| 阶段 | 内容 | 验证 |
|
||||||
|
|------|------|------|
|
||||||
|
| **M5 遮罩** | `build_inpaint_mask` + 黑线渲染 + 合成 RGBA | 目视:划线图正确、遮罩=头发∪发际线以下,透明区对 |
|
||||||
|
| **M6 ComfyUI 客户端** | `comfyui.py` 跑通一张(8182 起服务后) | 上传→prompt→取回 PNG,得到生发图 |
|
||||||
|
| **M7 接 service/app** | generate 时每种附带 grown,handler 返回新字段 | curl:results 含 grown_image_base64(合法 PNG) |
|
||||||
|
| **M8 文档/测试** | 更新接口文档;mock ComfyUI 的单测 + 真机冒烟 | pytest 绿;真机端到端出生发图 |
|
||||||
|
|
||||||
|
### 10.7 新增风险
|
||||||
|
|
||||||
|
1. **耗时**:同步 N 张 Flux,单请求数分钟级;需评估是否后续改异步/队列。
|
||||||
|
2. **ComfyUI 依赖外部进程**:8182 未起/模型未加载/节点缺失 → 该接口失败;worker `/health` 不体现 ComfyUI 状态(可加可选探测)。
|
||||||
|
3. **遮罩精度**:直接决定生发位置与自然度;M5 必须可视化核验,必要时拿手绘样本标定。
|
||||||
|
4. **GPU 共享**:ComfyUI 与接口1/2 的 CPU 推理同机;显存/算力调度需观察(ComfyUI 自带 torch,支持 5090)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **文档版本**: v1.1 | **创建日期**: 2026-06-14(v1.1:新增 §10 生发图生成 ComfyUI 管线)
|
||||||
|
> 算法来源: head3d(502 点 mesh + UV)+ Flux-2 Klein 9b(ComfyUI add_hair.json)| 运行位置: worker(GPU) + 本机 ComfyUI(8182)
|
||||||
|
> **产出**: ① 发际线曲线叠加预览图 ② 生发后图片(植发 3 个月效果)
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# 接口 3:B 端生发 — 技术实现方案(马克笔发际线检测 + 生发)
|
||||||
|
|
||||||
|
> 在 **高性能 worker(GPU 机)** 实现,与接口 1/2 同机。对外经网关代理。
|
||||||
|
> B 端:医生在患者额头**用马克笔画出规划的发际线**,拍照上传。系统**检测这条手绘线**,
|
||||||
|
> 据此生成生发图。检测算法移植自 `/home/xsl/headmark` 的调研结论(黑帽 + Dijkstra)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 契约(对齐 `接口文档.md` 接口3,不变)
|
||||||
|
|
||||||
|
`POST /api/v1/hair/grow-b`
|
||||||
|
|
||||||
|
| 输入 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `marked_image_*` | 已划线(医生标注发际线)的图,三选一,必填 |
|
||||||
|
| `original_image_*` | 原始用户照片,三选一,必填 |
|
||||||
|
|
||||||
|
| 输出 data | 决策 |
|
||||||
|
|-----------|------|
|
||||||
|
| `best_hairline_image_url` | **= 原图 original** 原样返回(worker 返回 `best_hairline_image_base64`) |
|
||||||
|
| `hair_growth_image_url` | **生发后图片**(ComfyUI,worker 返回 `hair_growth_image_base64`) |
|
||||||
|
| `hairline_type` | 固定 **`"custom"`**(手绘定制) |
|
||||||
|
|
||||||
|
> 落盘改 URL 由网关做(架构同接口1/2)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 马克笔发际线检测(核心,源自 headmark 调研)
|
||||||
|
|
||||||
|
headmark `docs/detection_research.md` 结论:全局灰度阈值不可用(笔迹平均灰度反而高于阈值、
|
||||||
|
与皮肤阴影分布重叠);推荐 **黑帽响应图 + 端点锚定 Dijkstra 最小路径**,实测误差 ≤0.5px(GT锚点)。
|
||||||
|
本项目用 **MediaPipe 锚点**(非 GT)实测平均 3.2px、中位 0px —— 对生成遮罩足够(线会膨胀成带)。
|
||||||
|
|
||||||
|
```
|
||||||
|
detect_marker_hairline(marked_bgr, landmarks, parse_map):
|
||||||
|
[1] ROI = forehead_upper_region(landmarks) ∩ head_silhouette(parse_map) # 复用接口2 mask.py
|
||||||
|
[2] 黑帽响应 bh = MORPH_BLACKHAT(gray, ksize=max(15,int(w*0.025)|1));ROI 外置 0
|
||||||
|
[3] 锚点 = MediaPipe 21(左鬓角)/251(右鬓角),各自小窗口(≈w*3%)内吸附到 bh 最大处
|
||||||
|
[4] 代价 cost = bh.max()-bh+1;ROI 外设 1e6;
|
||||||
|
path = skimage.graph.route_through_array(cost, 左锚, 右锚, fully_connected, geometric)
|
||||||
|
[5] 拒识:path 平均 bh 响应 < 阈值(可调) → None(上层返回 1001 "未检测到发际线划线")
|
||||||
|
return path # (N,2) row,col
|
||||||
|
```
|
||||||
|
|
||||||
|
- 依赖:**`scikit-image==0.24.0`**。⚠️ 0.25+ 强依赖 numpy≥2,会顶掉 mediapipe 的 numpy<2 →
|
||||||
|
mediapipe/SegFormer 全崩。**必须锁 0.24.x**。
|
||||||
|
- 复用接口2:`forehead_upper_region` / `head_silhouette`(`hairline/mask.py`)、SegFormer / MediaPipe 单例。
|
||||||
|
|
||||||
|
## 2. 遮罩 + 原图重画干净线
|
||||||
|
|
||||||
|
- **遮罩**:path → 画成 curve_mask → 复用接口2 `_above_curve_region` + `head` + `_clean_mask`
|
||||||
|
得到"发际线以上闭合区域"。
|
||||||
|
- **ComfyUI 输入图**:用 **原图 original**(按需缩放到 marked 尺寸对齐坐标),**重画一条干净黑线**
|
||||||
|
(检测 path 膨胀成线宽),避免医生手绘的毛刺/杂线干扰生成。
|
||||||
|
- 合成 RGBA:RGB=重画线的原图,alpha=255−mask(透明=重绘区)。复用 `compose_comfy_rgba`。
|
||||||
|
|
||||||
|
## 3. 生发(复用接口2 ComfyUI 客户端)
|
||||||
|
|
||||||
|
`hairline/comfyui.run(rgba_png)` → 跑 `add_hair.json`(Flux-2)→ 生发图 PNG。同步。
|
||||||
|
|
||||||
|
## 4. worker handler(`/api/v1/hair/grow-b`)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. marked + original 各三选一取图(复用 resolve_image_bytes)+ 校验(大小/解码/分辨率)
|
||||||
|
2. 在 marked 上:landmarks(MediaPipe)+parse(SegFormer) → detect_marker_hairline
|
||||||
|
- 无人脸 → 1001;未检测到画线 → 1001 "未检测到发际线划线"
|
||||||
|
3. 遮罩 + 原图重画线 → RGBA → comfyui.run → 生发图
|
||||||
|
4. return ok({ best_hairline_image_base64: 原图, hair_growth_image_base64: 生发图,
|
||||||
|
hairline_type: "custom" })
|
||||||
|
异常 → 1007;重活 run_in_threadpool。
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 开发步骤
|
||||||
|
|
||||||
|
| 阶段 | 内容 | 验证 |
|
||||||
|
|------|------|------|
|
||||||
|
| **M1 检测** | `hairline/marker_detect.py`(黑帽+锚点+Dijkstra+拒识) | headmark test_image:检测线贴合真值;无线图被拒识 |
|
||||||
|
| **M2 遮罩+重画** | path→遮罩(复用) + 原图重画干净线 + RGBA 合成 | 目视:干净线在原图、遮罩贴合 |
|
||||||
|
| **M3 接 app** | grow-b 真实实现 + 输出字段 + 1001 | curl:best=原图/grown 合法PNG/type=custom;无线→1001 |
|
||||||
|
| **M4 测试** | 检测/mask 单测 + mock-ComfyUI 集成 + 真机冒烟 | pytest 绿;真机出生发图 |
|
||||||
|
|
||||||
|
## 6. 风险
|
||||||
|
|
||||||
|
1. **锚点偏差/路径端点偏移**:MediaPipe 21/251 吸附后仍可能在鬓角端有偏移(实测 max~42px,少数点)。
|
||||||
|
膨胀成带 + 遮罩闭合可吸收;必要时改进吸附窗口或端点截断。
|
||||||
|
2. **没画线/画线极浅**:靠拒识阈值(路径平均黑帽响应)兜底,阈值需在更多真实图上标定。
|
||||||
|
3. **marked 与 original 尺寸/对齐不一致**:按 marked 坐标系处理,original 缩放对齐;若两图非同源(不同姿态)会错位——约定二者为"同一张照片的划线版/原始版"。
|
||||||
|
4. **抬头纹/眉毛/发丝干扰**:黑帽 + ROI + Dijkstra 平滑已大幅抑制(调研验证抬头纹零干扰),极端情况可在代价图抑制头发区域。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **文档版本**: v1.0 | **创建日期**: 2026-06-15 | 检测来源: headmark(黑帽+Dijkstra)|
|
||||||
|
> 生发: 复用接口2 ComfyUI(add_hair.json) | 运行位置: worker(GPU) + 本机 ComfyUI(8182)
|
||||||
@@ -198,10 +198,15 @@
|
|||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
| 字段 | 类型 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| image_url | string | 方案预览图 URL(当前 = 发际线叠加图) |
|
| image_url | string | 方案**预览图** URL(发际线曲线叠加图) |
|
||||||
|
| grown_image_url | string | **生发后图片** URL(ComfyUI/Flux「植发 3 个月」效果图) |
|
||||||
| hairline_type | string | 发际线类型 key:`ellipse`/`flower`/`heart`/`straight`/`wave`(female),`ellipse`/`m`/`straight`/`inverse_arc`(male) |
|
| hairline_type | string | 发际线类型 key:`ellipse`/`flower`/`heart`/`straight`/`wave`(female),`ellipse`/`m`/`straight`/`inverse_arc`(male) |
|
||||||
| order | int | 排序序号(当前阶段固定 `1..N`,按贴图顺序,暂不计算合适度) |
|
| order | int | 排序序号(当前阶段固定 `1..N`,按贴图顺序,暂不计算合适度) |
|
||||||
|
|
||||||
|
> ⚠️ 生发图由本机 ComfyUI(Flux-2,端口 8182)生成,**一次请求生成全部 N 张、同步返回**,
|
||||||
|
> 单请求耗时可达数分钟,调用方超时需放大。worker 侧返回 `image_base64` / `grown_image_base64`,
|
||||||
|
> 网关落盘后改写为上表的 `image_url` / `grown_image_url`。
|
||||||
|
|
||||||
### 响应示例
|
### 响应示例
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -211,8 +216,8 @@
|
|||||||
"request_id": "mock-request-id",
|
"request_id": "mock-request-id",
|
||||||
"data": {
|
"data": {
|
||||||
"results": [
|
"results": [
|
||||||
{ "image_url": "https://hair.xiangsilian.com/static/annotations/uuid1.png", "hairline_type": "ellipse", "order": 1 },
|
{ "image_url": "https://hair.xiangsilian.com/static/annotations/uuid1.png", "grown_image_url": "https://hair.xiangsilian.com/static/annotations/grown1.png", "hairline_type": "ellipse", "order": 1 },
|
||||||
{ "image_url": "https://hair.xiangsilian.com/static/annotations/uuid2.png", "hairline_type": "flower", "order": 2 }
|
{ "image_url": "https://hair.xiangsilian.com/static/annotations/uuid2.png", "grown_image_url": "https://hair.xiangsilian.com/static/annotations/grown2.png", "hairline_type": "flower", "order": 2 }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -310,21 +315,25 @@
|
|||||||
|
|
||||||
### 输入
|
### 输入
|
||||||
|
|
||||||
图片参数见「通用约定 → 图片传参字段」。本接口无其他专属参数。
|
图片参数见「通用约定 → 图片传参字段」。专属参数:
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| gender | string | **是** | 性别:`male` / `female`。决定返回的发际线集合(female 5 / male 4) |
|
||||||
|
|
||||||
### 输出(data)
|
### 输出(data)
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
| 字段 | 类型 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| hairline_images | object[] | N 张用户发际线 PNG,**数量 N 不固定**,已按合适度排序,元素见下表 |
|
| hairline_images | object[] | N 张发际线叠加图(发际线曲线叠在用户照片上,同接口2预览),**数量 = 该性别发际线数**,本期按贴图顺序,元素见下表 |
|
||||||
| best_hairline_center_point | object | 最合适发际线的「面部中间点」坐标,原图像素:`{ "x": number, "y": number }` |
|
| best_hairline_center_point | object | 最佳(order=1)发际线曲线的「面部中间点」坐标,原图像素:`{ "x": number, "y": number }` |
|
||||||
|
|
||||||
`hairline_images` 元素:
|
`hairline_images` 元素:
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
| 字段 | 类型 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| image_url | string | 发际线 PNG 图片 URL |
|
| image_url | string | 发际线叠加图 URL(worker 返回 `image_base64`,网关落盘后改写为 url) |
|
||||||
| order | int | 排序序号(1 = 最合适,依次递增) |
|
| order | int | 排序序号(本期固定 `1..N`,暂不计算合适度) |
|
||||||
|
|
||||||
### 响应示例(当前 Mock 返回值)
|
### 响应示例(当前 Mock 返回值)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"""ComfyUI 客户端:用 add_hair.json 工作流跑生发图(Flux-2 inpaint)。
|
||||||
|
|
||||||
|
worker 不跑 Flux,只把「划线图 + 遮罩」的 RGBA 上传到本机 ComfyUI(默认 8182),
|
||||||
|
替换工作流节点 26 的输入图、随机 seed,提交 /prompt,轮询 /history,取回 /view 输出。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
COMFYUI_URL = os.getenv("COMFYUI_URL", "http://127.0.0.1:8182").rstrip("/")
|
||||||
|
WORKFLOW_PATH = os.getenv(
|
||||||
|
"ADD_HAIR_WORKFLOW",
|
||||||
|
os.path.join(os.path.dirname(os.path.dirname(__file__)), "add_hair.json"),
|
||||||
|
)
|
||||||
|
COMFY_TIMEOUT = float(os.getenv("COMFYUI_TIMEOUT", "600")) # 单张出图最长等待(秒)
|
||||||
|
|
||||||
|
_INPUT_NODE = "26" # LoadImage:外部输入图(含 alpha 遮罩)
|
||||||
|
_SEED_NODE = "6" # RandomNoise
|
||||||
|
_OUTPUT_NODE = "17" # SaveImage
|
||||||
|
|
||||||
|
_workflow = None
|
||||||
|
|
||||||
|
|
||||||
|
def _load_workflow() -> dict:
|
||||||
|
global _workflow
|
||||||
|
if _workflow is None:
|
||||||
|
with open(WORKFLOW_PATH, encoding="utf-8") as f:
|
||||||
|
_workflow = json.load(f)
|
||||||
|
return _workflow
|
||||||
|
|
||||||
|
|
||||||
|
def run(rgba_png_bytes: bytes, timeout: float = COMFY_TIMEOUT) -> bytes:
|
||||||
|
"""提交一次生发任务,返回输出 PNG 字节。失败抛异常。"""
|
||||||
|
client_id = uuid.uuid4().hex
|
||||||
|
with httpx.Client(base_url=COMFYUI_URL, timeout=30.0) as cli:
|
||||||
|
# 1. 上传输入图(含 alpha 遮罩)到 ComfyUI input 目录
|
||||||
|
fname = f"hair_{client_id}.png"
|
||||||
|
r = cli.post("/upload/image", files={"image": (fname, rgba_png_bytes, "image/png")},
|
||||||
|
data={"overwrite": "true", "type": "input"})
|
||||||
|
r.raise_for_status()
|
||||||
|
up = r.json()
|
||||||
|
name = (up.get("subfolder") + "/" if up.get("subfolder") else "") + up["name"]
|
||||||
|
|
||||||
|
# 2. 改工作流:节点26 输入图 + 随机 seed
|
||||||
|
wf = copy.deepcopy(_load_workflow())
|
||||||
|
wf[_INPUT_NODE]["inputs"]["image"] = name
|
||||||
|
wf[_SEED_NODE]["inputs"]["noise_seed"] = random.randint(0, 2**63 - 1)
|
||||||
|
|
||||||
|
# 3. 提交
|
||||||
|
r = cli.post("/prompt", json={"prompt": wf, "client_id": client_id})
|
||||||
|
r.raise_for_status()
|
||||||
|
prompt_id = r.json()["prompt_id"]
|
||||||
|
|
||||||
|
# 4. 轮询 /history
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
outputs = None
|
||||||
|
while time.time() < deadline:
|
||||||
|
hr = cli.get(f"/history/{prompt_id}")
|
||||||
|
hr.raise_for_status()
|
||||||
|
hist = hr.json()
|
||||||
|
if prompt_id in hist:
|
||||||
|
entry = hist[prompt_id]
|
||||||
|
status = entry.get("status", {})
|
||||||
|
if status.get("status_str") == "error":
|
||||||
|
raise RuntimeError(f"ComfyUI 执行报错: {status}")
|
||||||
|
outputs = entry.get("outputs")
|
||||||
|
if outputs and _OUTPUT_NODE in outputs:
|
||||||
|
break
|
||||||
|
time.sleep(1.0)
|
||||||
|
if not outputs or _OUTPUT_NODE not in outputs:
|
||||||
|
raise TimeoutError(f"ComfyUI 出图超时({timeout}s) prompt_id={prompt_id}")
|
||||||
|
|
||||||
|
# 5. 取回输出图
|
||||||
|
imgs = outputs[_OUTPUT_NODE].get("images") or []
|
||||||
|
if not imgs:
|
||||||
|
raise RuntimeError("ComfyUI 输出无图像")
|
||||||
|
info = imgs[0]
|
||||||
|
vr = cli.get("/view", params={"filename": info["filename"],
|
||||||
|
"subfolder": info.get("subfolder", ""),
|
||||||
|
"type": info.get("type", "output")})
|
||||||
|
vr.raise_for_status()
|
||||||
|
return vr.content
|
||||||
|
|
||||||
|
|
||||||
|
def ping() -> bool:
|
||||||
|
"""探测 ComfyUI 是否在线(/system_stats)。"""
|
||||||
|
try:
|
||||||
|
with httpx.Client(base_url=COMFYUI_URL, timeout=3.0) as cli:
|
||||||
|
return cli.get("/system_stats").status_code == 200
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
inp = sys.argv[1] if len(sys.argv) > 1 else "tests/output/comfy_input.png"
|
||||||
|
print("ComfyUI:", COMFYUI_URL, "online:", ping())
|
||||||
|
with open(inp, "rb") as f:
|
||||||
|
png = run(f.read())
|
||||||
|
out = "tests/output/grown.png"
|
||||||
|
with open(out, "wb") as f:
|
||||||
|
f.write(png)
|
||||||
|
print(f"生发图已存 {out}({len(png)} bytes)")
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"""接口3:马克笔手绘发际线检测(黑帽响应图 + 端点锚定 Dijkstra 最小路径)。
|
||||||
|
|
||||||
|
源自 /home/xsl/headmark 调研结论:全局灰度阈值不可用(笔迹平均灰度反高于阈值、
|
||||||
|
与皮肤阴影分布重叠);黑帽变换响应"比局部邻域暗的细结构",叠加 ROI + 两鬓角锚点间
|
||||||
|
最小代价路径,对抬头纹/眉毛/发丝鲁棒。复用接口2 的 ROI(额头上部 ∩ 头部分割)。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
from skimage.graph import route_through_array
|
||||||
|
|
||||||
|
from .mask import forehead_upper_region, head_silhouette
|
||||||
|
|
||||||
|
# 鬓角锚点(MediaPipe canonical 索引):21 左、251 右
|
||||||
|
ANCHOR_LEFT = 21
|
||||||
|
ANCHOR_RIGHT = 251
|
||||||
|
# 拒识阈值:路径平均黑帽响应低于此值 → 判"未检测到画线"(待真实图标定)
|
||||||
|
MIN_MEAN_RESPONSE = 8.0
|
||||||
|
|
||||||
|
|
||||||
|
def _blackhat(gray: np.ndarray, w: int) -> np.ndarray:
|
||||||
|
k = max(15, int(w * 0.025) | 1)
|
||||||
|
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (k, k))
|
||||||
|
return cv2.morphologyEx(gray, cv2.MORPH_BLACKHAT, kernel).astype(np.float32)
|
||||||
|
|
||||||
|
|
||||||
|
def _snap_anchor(bh_roi: np.ndarray, x: int, y: int, w: int):
|
||||||
|
"""在 (x,y) 周围窗口内吸附到黑帽响应最大处,返回 (row, col)。"""
|
||||||
|
win = max(8, int(w * 0.03))
|
||||||
|
h, ww = bh_roi.shape
|
||||||
|
x0, x1 = max(0, x - win), min(ww, x + win)
|
||||||
|
y0, y1 = max(0, y - win), min(h, y + win)
|
||||||
|
sub = bh_roi[y0:y1, x0:x1]
|
||||||
|
if sub.size == 0 or sub.max() <= 0:
|
||||||
|
return (int(np.clip(y, 0, h - 1)), int(np.clip(x, 0, ww - 1)))
|
||||||
|
dy, dx = np.unravel_index(int(np.argmax(sub)), sub.shape)
|
||||||
|
return (y0 + dy, x0 + dx)
|
||||||
|
|
||||||
|
|
||||||
|
def detect_marker_hairline(marked_bgr: np.ndarray, landmarks_mp: np.ndarray,
|
||||||
|
parse_map: np.ndarray, min_mean_response: float = MIN_MEAN_RESPONSE):
|
||||||
|
"""检测手绘发际线,返回路径 (N,2) row,col;未检出/被拒识返回 None。"""
|
||||||
|
h, w = marked_bgr.shape[:2]
|
||||||
|
roi = cv2.bitwise_and(forehead_upper_region(landmarks_mp, w, h),
|
||||||
|
head_silhouette(parse_map)) > 0
|
||||||
|
if roi.sum() == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
gray = cv2.cvtColor(marked_bgr, cv2.COLOR_BGR2GRAY)
|
||||||
|
bh = _blackhat(gray, w)
|
||||||
|
bh_roi = bh * roi
|
||||||
|
|
||||||
|
al = _snap_anchor(bh_roi, int(landmarks_mp[ANCHOR_LEFT, 0] * w),
|
||||||
|
int(landmarks_mp[ANCHOR_LEFT, 1] * h), w)
|
||||||
|
ar = _snap_anchor(bh_roi, int(landmarks_mp[ANCHOR_RIGHT, 0] * w),
|
||||||
|
int(landmarks_mp[ANCHOR_RIGHT, 1] * h), w)
|
||||||
|
|
||||||
|
cost = (bh.max() - bh) + 1.0
|
||||||
|
cost[~roi] = 1e6 # 禁止路径走出 ROI
|
||||||
|
path, _ = route_through_array(cost, al, ar, fully_connected=True, geometric=True)
|
||||||
|
path = np.asarray(path)
|
||||||
|
|
||||||
|
# 拒识:路径平均黑帽响应过低 → 没画线(强行找出的伪路径)
|
||||||
|
if float(bh[path[:, 0], path[:, 1]].mean()) < min_mean_response:
|
||||||
|
return None
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def path_to_curve_mask(path: np.ndarray, h: int, w: int, thickness: int = 3) -> np.ndarray:
|
||||||
|
"""把路径画成曲线 mask(uint8 0/255),用作遮罩下边界 / 重画干净线。"""
|
||||||
|
m = np.zeros((h, w), np.uint8)
|
||||||
|
pts = path[:, ::-1].reshape(-1, 1, 2) # (row,col)→(x,y)
|
||||||
|
cv2.polylines(m, [pts], False, 255, thickness, lineType=cv2.LINE_AA)
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
from .service import get_landmarker, get_parser
|
||||||
|
|
||||||
|
path_img = sys.argv[1] if len(sys.argv) > 1 else "/home/xsl/headmark/test_image/input1.png"
|
||||||
|
img = cv2.imread(path_img)
|
||||||
|
if img is None:
|
||||||
|
print(f"无法读取 {path_img}"); sys.exit(1)
|
||||||
|
h, w = img.shape[:2]
|
||||||
|
rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
|
||||||
|
lm = get_landmarker().detect(rgb)
|
||||||
|
if lm is None:
|
||||||
|
print("未检出人脸"); sys.exit(1)
|
||||||
|
pm = get_parser().parse(rgb)
|
||||||
|
p = detect_marker_hairline(img, lm, pm)
|
||||||
|
if p is None:
|
||||||
|
print("未检测到发际线划线(拒识)"); sys.exit(0)
|
||||||
|
print(f"检测到画线:{len(p)} 点")
|
||||||
|
vis = img.copy()
|
||||||
|
cv2.polylines(vis, [p[:, ::-1].reshape(-1, 1, 2)], False, (0, 0, 255), 2)
|
||||||
|
import os
|
||||||
|
os.makedirs("tests/output", exist_ok=True)
|
||||||
|
name = os.path.splitext(os.path.basename(path_img))[0]
|
||||||
|
cv2.imwrite(f"tests/output/marker_{name}.png", vis)
|
||||||
|
print(f"saved tests/output/marker_{name}.png")
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"""接口2 第二步:inpaint 遮罩 + 黑色发际线划线合成(参考 headmark 5步法)。
|
||||||
|
|
||||||
|
算法(用 hairline_texture_black 渲染黑线替代 headmark 的手绘检测):
|
||||||
|
① 额头上部区域:MediaPipe 额头边界关键点连线,向上+两侧补到图像边缘填充
|
||||||
|
② 头部轮廓:SegFormer 头部类(hair∪skin∪…,排除 bg/neck/cloth)
|
||||||
|
③ ROI = ① ∩ ②
|
||||||
|
④ 渲染黑色发际线 → 烧进照片(marked) + 得到曲线像素
|
||||||
|
⑤ mask = ROI 中"发际线曲线以上",闭运算去洞 + 最大连通域 + 轻羽化
|
||||||
|
合成 RGBA:RGB=marked,alpha=255×(1−mask)(透明=重绘区,对齐 ComfyUI mask=1−alpha)。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from .render import load_ext_mesh, load_texture_rgba, render_hairline_overlay, build_overlay_layer
|
||||||
|
|
||||||
|
# headmark 额头边界关键点(MediaPipe canonical 索引,左→右沿上额)
|
||||||
|
FOREHEAD_LANDMARKS = [21, 68, 104, 69, 108, 151, 337, 299, 333, 298, 251]
|
||||||
|
# SegFormer 头部类(含 skin..hat;排除 bg=0 / ear_r=15 / neck_l=16 / neck=17 / cloth=18)
|
||||||
|
_HEAD_CLASSES = list(range(1, 15))
|
||||||
|
|
||||||
|
|
||||||
|
def forehead_upper_region(landmarks_mp: np.ndarray, w: int, h: int) -> np.ndarray:
|
||||||
|
"""headmark step1:额头边界关键点以上的"上部区域"填充 mask(uint8 0/255)。"""
|
||||||
|
pts = [(int(landmarks_mp[i, 0] * w), int(landmarks_mp[i, 1] * h)) for i in FOREHEAD_LANDMARKS]
|
||||||
|
left_ext = (0, pts[0][1])
|
||||||
|
right_ext = (w - 1, pts[-1][1])
|
||||||
|
polygon = np.array([left_ext] + pts + [right_ext, (w - 1, 0), (0, 0)], dtype=np.int32)
|
||||||
|
m = np.zeros((h, w), np.uint8)
|
||||||
|
cv2.fillPoly(m, [polygon], 255)
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
def head_silhouette(parse_map: np.ndarray) -> np.ndarray:
|
||||||
|
"""headmark step2:SegFormer 头部轮廓 mask(uint8 0/255)。"""
|
||||||
|
return (np.isin(parse_map, _HEAD_CLASSES).astype(np.uint8) * 255)
|
||||||
|
|
||||||
|
|
||||||
|
def _curve_bottom_per_column(curve_mask: np.ndarray):
|
||||||
|
"""每列发际线曲线的**最低**像素 y(线下沿),返回 (xs, ys) 仅含有曲线的列。"""
|
||||||
|
ys_idx, xs_idx = np.where(curve_mask > 0)
|
||||||
|
if xs_idx.size == 0:
|
||||||
|
return None, None
|
||||||
|
w = curve_mask.shape[1]
|
||||||
|
bottom = np.full(w, -1, np.int32)
|
||||||
|
np.maximum.at(bottom, xs_idx, ys_idx)
|
||||||
|
cols = np.where(bottom >= 0)[0]
|
||||||
|
return cols, bottom[cols]
|
||||||
|
|
||||||
|
|
||||||
|
def _above_curve_region(curve_mask: np.ndarray, h: int, w: int) -> np.ndarray:
|
||||||
|
"""由发际线曲线得到"曲线以上"区域(uint8 0/255)。
|
||||||
|
|
||||||
|
曲线 x 跨度内逐列插值出下沿 y_line(x),两侧按端点 y 水平延伸;
|
||||||
|
above = 所有 y ≤ y_line(x)。曲线缺失(极端)则返回全 1(交给 ROI 兜底)。
|
||||||
|
"""
|
||||||
|
cols, ybot = _curve_bottom_per_column(curve_mask)
|
||||||
|
if cols is None:
|
||||||
|
return np.full((h, w), 255, np.uint8)
|
||||||
|
x0, x1 = int(cols.min()), int(cols.max())
|
||||||
|
# 全列插值 y_line:[x0,x1] 内线性插值,两侧水平延伸
|
||||||
|
yline = np.interp(np.arange(w), cols, ybot,
|
||||||
|
left=float(ybot[0]), right=float(ybot[-1])).astype(np.int32)
|
||||||
|
yy = np.arange(h)[:, None] # (h,1)
|
||||||
|
above = (yy <= yline[None, :]).astype(np.uint8) * 255 # (h,w)
|
||||||
|
return above
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_mask(mask: np.ndarray, w: int) -> np.ndarray:
|
||||||
|
"""闭运算去洞 + 取最大连通域填充 + 轻羽化。"""
|
||||||
|
k = max(3, (int(w * 0.015) | 1))
|
||||||
|
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (k, k))
|
||||||
|
closed = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
|
||||||
|
cnts, _ = cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
|
out = np.zeros_like(mask)
|
||||||
|
if cnts:
|
||||||
|
largest = max(cnts, key=cv2.contourArea)
|
||||||
|
cv2.drawContours(out, [largest], -1, 255, -1)
|
||||||
|
# 轻羽化(柔化边缘,利于扩散衔接)
|
||||||
|
out = cv2.GaussianBlur(out, (0, 0), sigmaX=max(1.0, w * 0.004))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def mask_from_curve(curve_mask: np.ndarray, landmarks_mp: np.ndarray,
|
||||||
|
parse_map: np.ndarray) -> np.ndarray:
|
||||||
|
"""由发际线曲线 + ROI(额头上部 ∩ 头部) 围成"曲线以上"闭合遮罩(uint8 0..255)。
|
||||||
|
|
||||||
|
接口2(模板渲染曲线) 与 接口3(检测路径曲线) 共用。
|
||||||
|
"""
|
||||||
|
h, w = curve_mask.shape[:2]
|
||||||
|
roi = cv2.bitwise_and(forehead_upper_region(landmarks_mp, w, h),
|
||||||
|
head_silhouette(parse_map))
|
||||||
|
above = _above_curve_region(curve_mask, h, w)
|
||||||
|
return _clean_mask(cv2.bitwise_and(roi, above), w)
|
||||||
|
|
||||||
|
|
||||||
|
def build_inpaint_mask(photo_bgr: np.ndarray, landmarks_mp: np.ndarray,
|
||||||
|
parse_map: np.ndarray, points502: np.ndarray,
|
||||||
|
black_texture_rgba: np.ndarray):
|
||||||
|
"""接口2:返回 (marked_bgr 划线图, mask uint8 0..255 重绘区)。"""
|
||||||
|
h, w = photo_bgr.shape[:2]
|
||||||
|
uv, ext_faces = load_ext_mesh()
|
||||||
|
marked = render_hairline_overlay(photo_bgr, points502, ext_faces, uv, black_texture_rgba)
|
||||||
|
overlay = build_overlay_layer(h, w, points502, ext_faces, uv, black_texture_rgba)
|
||||||
|
curve_mask = (overlay[:, :, 3] > 40).astype(np.uint8) * 255
|
||||||
|
mask = mask_from_curve(curve_mask, landmarks_mp, parse_map)
|
||||||
|
return marked, mask
|
||||||
|
|
||||||
|
|
||||||
|
def compose_comfy_rgba(marked_bgr: np.ndarray, mask: np.ndarray) -> Image.Image:
|
||||||
|
"""合成 ComfyUI LoadImage 用的 RGBA:RGB=划线图,alpha=255−mask(透明=重绘区)。"""
|
||||||
|
rgb = cv2.cvtColor(marked_bgr, cv2.COLOR_BGR2RGB)
|
||||||
|
alpha = (255 - mask).astype(np.uint8)
|
||||||
|
rgba = np.dstack([rgb, alpha])
|
||||||
|
return Image.fromarray(rgba, mode="RGBA")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys, os
|
||||||
|
from .service import get_landmarker, get_parser
|
||||||
|
|
||||||
|
path = sys.argv[1] if len(sys.argv) > 1 else "tests/fixtures/frontal.jpg"
|
||||||
|
tex_name = sys.argv[2] if len(sys.argv) > 2 else "girl_straight"
|
||||||
|
img = cv2.imread(path)
|
||||||
|
h, w = img.shape[:2]
|
||||||
|
rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
|
||||||
|
|
||||||
|
from .hairline_2d import sample_hairline, smooth_hairline
|
||||||
|
from .lift_3d import lift_hairline_to_3d, build_middle_row, assemble_full
|
||||||
|
lm = get_landmarker().detect(rgb)
|
||||||
|
parse_map = get_parser().parse(rgb)
|
||||||
|
h2d, valid = sample_hairline(lm, parse_map); h2d = smooth_hairline(h2d, valid)
|
||||||
|
h3d = lift_hairline_to_3d(lm, h2d); mid = build_middle_row(lm, h3d)
|
||||||
|
pts = assemble_full(lm, mid, h3d)
|
||||||
|
|
||||||
|
black = load_texture_rgba(f"hairline_texture_black/{tex_name}.png")
|
||||||
|
marked, mask = build_inpaint_mask(img, lm, parse_map, pts, black)
|
||||||
|
os.makedirs("tests/output", exist_ok=True)
|
||||||
|
cv2.imwrite("tests/output/mask_marked.png", marked)
|
||||||
|
cv2.imwrite("tests/output/mask_binary.png", mask)
|
||||||
|
# 三联可视化:划线图 / ROI / mask 叠加
|
||||||
|
upper = forehead_upper_region(lm, w, h); head = head_silhouette(parse_map)
|
||||||
|
roi = cv2.bitwise_and(upper, head)
|
||||||
|
vis = marked.copy()
|
||||||
|
vis[roi > 0] = (vis[roi > 0] * 0.6 + np.array([0, 40, 0])).clip(0, 255).astype(np.uint8)
|
||||||
|
vis[mask > 128] = (vis[mask > 128] * 0.4 + np.array([0, 0, 150])).clip(0, 255).astype(np.uint8)
|
||||||
|
cv2.imwrite("tests/output/mask_vis.png", vis)
|
||||||
|
compose_comfy_rgba(marked, mask).save("tests/output/comfy_input.png")
|
||||||
|
print(f"saved mask_marked/mask_binary/mask_vis/comfy_input;mask 像素 {int((mask>128).sum())}")
|
||||||
@@ -78,19 +78,30 @@ def render_hairline_overlay(photo_bgr: np.ndarray,
|
|||||||
points502_norm: (502, 3) 归一化坐标(x,y ∈ [0,1]),**MP 顺序**(extract_hairline 输出)。
|
points502_norm: (502, 3) 归一化坐标(x,y ∈ [0,1]),**MP 顺序**(extract_hairline 输出)。
|
||||||
"""
|
"""
|
||||||
H, W = photo_bgr.shape[:2]
|
H, W = photo_bgr.shape[:2]
|
||||||
TH, TW = texture_rgba.shape[:2]
|
overlay = build_overlay_layer(H, W, points502_norm, ext_faces, uv502, texture_rgba)
|
||||||
pts_obj = mp_order_to_obj_order(points502_norm) # MP序 → OBJ序
|
# alpha 合成(RGBA→BGR:贴图 RGB 顺序需反成 BGR)
|
||||||
img_xy = pts_obj[:, :2] * np.array([W, H], dtype=np.float32) # (502,2)
|
a = overlay[:, :, 3:4] / 255.0
|
||||||
|
rgb = overlay[:, :, :3][..., ::-1] # RGB→BGR
|
||||||
|
out = photo_bgr.astype(np.float32) * (1.0 - a) + rgb * a
|
||||||
|
return np.clip(out, 0, 255).astype(np.uint8)
|
||||||
|
|
||||||
overlay = np.zeros((H, W, 4), np.float32) # 累积曲线层 RGBA
|
|
||||||
|
def build_overlay_layer(H, W, points502_norm, ext_faces, uv502, texture_rgba) -> np.ndarray:
|
||||||
|
"""渲染发际线曲线层,返回 (H, W, 4) float32 RGBA(未合成到照片)。
|
||||||
|
|
||||||
|
供渲染合成(render_hairline_overlay)与遮罩(mask.py 取 alpha=曲线像素)共用。
|
||||||
|
"""
|
||||||
|
TH, TW = texture_rgba.shape[:2]
|
||||||
|
pts_obj = mp_order_to_obj_order(points502_norm)
|
||||||
|
img_xy = pts_obj[:, :2] * np.array([W, H], dtype=np.float32)
|
||||||
|
overlay = np.zeros((H, W, 4), np.float32)
|
||||||
tex = texture_rgba.astype(np.float32)
|
tex = texture_rgba.astype(np.float32)
|
||||||
for (i, j, k) in ext_faces:
|
for (i, j, k) in ext_faces:
|
||||||
dst = img_xy[[i, j, k]].astype(np.float32)
|
dst = img_xy[[i, j, k]].astype(np.float32)
|
||||||
# UV → 贴图像素;flipY:贴图 y = (1 - v_raw) * TH(与 head3d Three.js flipY=true 一致)
|
# UV → 贴图像素;flipY:贴图 y = (1 - v_raw) * TH(与 head3d Three.js flipY=true 一致)
|
||||||
src = np.array([[uv502[v][0] * TW, (1.0 - uv502[v][1]) * TH] for v in (i, j, k)],
|
src = np.array([[uv502[v][0] * TW, (1.0 - uv502[v][1]) * TH] for v in (i, j, k)],
|
||||||
dtype=np.float32)
|
dtype=np.float32)
|
||||||
# 退化三角形(投影到一条线)跳过,避免 getAffineTransform 奇异
|
if cv2.contourArea(dst.astype(np.int32)) < 1.0: # 退化三角形跳过
|
||||||
if cv2.contourArea(dst.astype(np.int32)) < 1.0:
|
|
||||||
continue
|
continue
|
||||||
M = cv2.getAffineTransform(src, dst)
|
M = cv2.getAffineTransform(src, dst)
|
||||||
warped = cv2.warpAffine(tex, M, (W, H), flags=cv2.INTER_LINEAR,
|
warped = cv2.warpAffine(tex, M, (W, H), flags=cv2.INTER_LINEAR,
|
||||||
@@ -99,9 +110,4 @@ def render_hairline_overlay(photo_bgr: np.ndarray,
|
|||||||
cv2.fillConvexPoly(tri_mask, dst.astype(np.int32), 255)
|
cv2.fillConvexPoly(tri_mask, dst.astype(np.int32), 255)
|
||||||
sel = tri_mask > 0
|
sel = tri_mask > 0
|
||||||
overlay[sel] = warped[sel]
|
overlay[sel] = warped[sel]
|
||||||
|
return overlay
|
||||||
# alpha 合成(RGBA→BGR:贴图 RGB 顺序需反成 BGR)
|
|
||||||
a = overlay[:, :, 3:4] / 255.0
|
|
||||||
rgb = overlay[:, :, :3][..., ::-1] # RGB→BGR
|
|
||||||
out = photo_bgr.astype(np.float32) * (1.0 - a) + rgb * a
|
|
||||||
return np.clip(out, 0, 255).astype(np.uint8)
|
|
||||||
|
|||||||
@@ -11,13 +11,23 @@ import cv2
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from . import constants as C
|
from . import constants as C
|
||||||
|
from . import comfyui
|
||||||
from .face_landmarks import FaceLandmarker
|
from .face_landmarks import FaceLandmarker
|
||||||
from .face_parsing import FaceParser
|
from .face_parsing import FaceParser
|
||||||
from .hairline_2d import sample_hairline, smooth_hairline
|
from .hairline_2d import sample_hairline, smooth_hairline
|
||||||
from .lift_3d import lift_hairline_to_3d, build_middle_row, assemble_full
|
from .lift_3d import lift_hairline_to_3d, build_middle_row, assemble_full
|
||||||
from .render import load_ext_mesh, load_texture_rgba, render_hairline_overlay
|
from .render import load_ext_mesh, load_texture_rgba, render_hairline_overlay, build_overlay_layer
|
||||||
|
from .mask import build_inpaint_mask, compose_comfy_rgba, mask_from_curve
|
||||||
|
from .marker_detect import detect_marker_hairline, path_to_curve_mask
|
||||||
|
|
||||||
_TEXTURE_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "hairline_texture")
|
import io
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger("hair.worker")
|
||||||
|
|
||||||
|
_REPO = os.path.dirname(os.path.dirname(__file__))
|
||||||
|
_TEXTURE_DIR = os.path.join(_REPO, "hairline_texture")
|
||||||
|
_BLACK_TEXTURE_DIR = os.path.join(_REPO, "hairline_texture_black")
|
||||||
|
|
||||||
# ⚠️ 本 worker 是 RTX 5090(sm_120),torch 2.2.2(cu121) 只编到 sm_90,CUDA 跑算子会报
|
# ⚠️ 本 worker 是 RTX 5090(sm_120),torch 2.2.2(cu121) 只编到 sm_90,CUDA 跑算子会报
|
||||||
# "no kernel image"。SegFormer 默认走 CPU(~2.5s/张)。换 torch cu128 后可设 SEG_DEVICE=cuda。
|
# "no kernel image"。SegFormer 默认走 CPU(~2.5s/张)。换 torch cu128 后可设 SEG_DEVICE=cuda。
|
||||||
@@ -73,40 +83,152 @@ def get_texture_map() -> dict:
|
|||||||
|
|
||||||
def extract_502(image_bgr: np.ndarray):
|
def extract_502(image_bgr: np.ndarray):
|
||||||
"""照片(BGR) → (points502 MP序, valid17)。无人脸返回 (None, None)。"""
|
"""照片(BGR) → (points502 MP序, valid17)。无人脸返回 (None, None)。"""
|
||||||
|
ctx = extract_context(image_bgr)
|
||||||
|
if ctx is None:
|
||||||
|
return None, None
|
||||||
|
return ctx["points"], ctx["valid"]
|
||||||
|
|
||||||
|
|
||||||
|
def extract_context(image_bgr: np.ndarray):
|
||||||
|
"""照片(BGR) → {landmarks, parse_map, points, valid}。无人脸返回 None。"""
|
||||||
rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB)
|
rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB)
|
||||||
landmarks = get_landmarker().detect(rgb)
|
landmarks = get_landmarker().detect(rgb)
|
||||||
if landmarks is None:
|
if landmarks is None:
|
||||||
return None, None
|
return None
|
||||||
parse_map = get_parser().parse(rgb)
|
parse_map = get_parser().parse(rgb)
|
||||||
hairline_2d, valid = sample_hairline(landmarks, parse_map)
|
hairline_2d, valid = sample_hairline(landmarks, parse_map)
|
||||||
hairline_2d = smooth_hairline(hairline_2d, valid)
|
hairline_2d = smooth_hairline(hairline_2d, valid)
|
||||||
hairline_3d = lift_hairline_to_3d(landmarks, hairline_2d)
|
hairline_3d = lift_hairline_to_3d(landmarks, hairline_2d)
|
||||||
middle_3d = build_middle_row(landmarks, hairline_3d)
|
middle_3d = build_middle_row(landmarks, hairline_3d)
|
||||||
points = assemble_full(landmarks, middle_3d, hairline_3d)
|
points = assemble_full(landmarks, middle_3d, hairline_3d)
|
||||||
return points, valid
|
return {"landmarks": landmarks, "parse_map": parse_map, "points": points, "valid": valid}
|
||||||
|
|
||||||
|
|
||||||
|
def _black_texture_path(white_path: str) -> str:
|
||||||
|
"""白贴图路径 → 同名黑贴图路径(hairline_texture_black/)。"""
|
||||||
|
return os.path.join(_BLACK_TEXTURE_DIR, os.path.basename(white_path))
|
||||||
|
|
||||||
|
|
||||||
def generate_previews(image_bgr: np.ndarray, gender: str):
|
def generate_previews(image_bgr: np.ndarray, gender: str):
|
||||||
"""生成该性别全部发际线预览图。
|
"""生成该性别全部发际线预览图(仅预览,不生发)。
|
||||||
|
|
||||||
Returns: list[dict],每项 {"hairline_type": key, "image_bgr": ndarray, "order": 1..N}。
|
Returns: list[dict] {"hairline_type", "image_bgr", "order"};无人脸返回 None。
|
||||||
无人脸返回 None。gender 必须是 male/female。
|
|
||||||
"""
|
"""
|
||||||
if gender not in ("male", "female"):
|
if gender not in ("male", "female"):
|
||||||
raise ValueError(f"gender 必须是 male/female,收到 {gender!r}")
|
raise ValueError(f"gender 必须是 male/female,收到 {gender!r}")
|
||||||
points, _valid = extract_502(image_bgr)
|
ctx = extract_context(image_bgr)
|
||||||
if points is None:
|
if ctx is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
uv, ext_faces = load_ext_mesh()
|
uv, ext_faces = load_ext_mesh()
|
||||||
results = []
|
results = []
|
||||||
for order, (key, path) in enumerate(get_texture_map()[gender], start=1):
|
for order, (key, path) in enumerate(get_texture_map()[gender], start=1):
|
||||||
tex = load_texture_rgba(path)
|
preview = render_hairline_overlay(image_bgr, ctx["points"], ext_faces, uv,
|
||||||
preview = render_hairline_overlay(image_bgr, points, ext_faces, uv, tex)
|
load_texture_rgba(path))
|
||||||
results.append({"hairline_type": key, "image_bgr": preview, "order": order})
|
results.append({"hairline_type": key, "image_bgr": preview, "order": order})
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def generate_grow_results(image_bgr: np.ndarray, gender: str):
|
||||||
|
"""该性别全部发际线:预览图(白线) + 生发图(ComfyUI)。同步、串行。
|
||||||
|
|
||||||
|
Returns: list[dict] {"hairline_type","order","image_bgr"(预览), "grown_png"(bytes 或 None)}。
|
||||||
|
无人脸返回 None。某张 ComfyUI 失败时该项 grown_png=None,不影响其余。
|
||||||
|
"""
|
||||||
|
if gender not in ("male", "female"):
|
||||||
|
raise ValueError(f"gender 必须是 male/female,收到 {gender!r}")
|
||||||
|
ctx = extract_context(image_bgr)
|
||||||
|
if ctx is None:
|
||||||
|
return None
|
||||||
|
uv, ext_faces = load_ext_mesh()
|
||||||
|
results = []
|
||||||
|
for order, (key, white_path) in enumerate(get_texture_map()[gender], start=1):
|
||||||
|
white = load_texture_rgba(white_path)
|
||||||
|
preview = render_hairline_overlay(image_bgr, ctx["points"], ext_faces, uv, white)
|
||||||
|
|
||||||
|
grown_png = None
|
||||||
|
try:
|
||||||
|
black = load_texture_rgba(_black_texture_path(white_path))
|
||||||
|
marked, mask = build_inpaint_mask(
|
||||||
|
image_bgr, ctx["landmarks"], ctx["parse_map"], ctx["points"], black)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
compose_comfy_rgba(marked, mask).save(buf, format="PNG")
|
||||||
|
grown_png = comfyui.run(buf.getvalue())
|
||||||
|
except Exception as e: # noqa: BLE001 单张失败不拖垮整请求
|
||||||
|
logger.warning("接口2 生发图失败 type=%s:%s", key, e)
|
||||||
|
|
||||||
|
results.append({"hairline_type": key, "order": order,
|
||||||
|
"image_bgr": preview, "grown_png": grown_png})
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def generate_hairline_pngs(image_bgr: np.ndarray, gender: str):
|
||||||
|
"""接口5:该性别全部发际线叠图(同接口2预览) + 最佳(order1)发际线曲线的面部中间点。
|
||||||
|
|
||||||
|
Returns: {"images":[{hairline_type,order,image_bgr}], "best_center":(x,y)};无人脸 None。
|
||||||
|
"""
|
||||||
|
if gender not in ("male", "female"):
|
||||||
|
raise ValueError(f"gender 必须是 male/female,收到 {gender!r}")
|
||||||
|
ctx = extract_context(image_bgr)
|
||||||
|
if ctx is None:
|
||||||
|
return None
|
||||||
|
h, w = image_bgr.shape[:2]
|
||||||
|
uv, ext_faces = load_ext_mesh()
|
||||||
|
lm = ctx["landmarks"]
|
||||||
|
# 面部中轴 x = 眉心(9/151 中点)
|
||||||
|
face_cx = float((lm[9, 0] + lm[151, 0]) / 2 * w)
|
||||||
|
|
||||||
|
textures = get_texture_map()[gender]
|
||||||
|
images, best_center = [], None
|
||||||
|
for order, (key, path) in enumerate(textures, start=1):
|
||||||
|
white = load_texture_rgba(path)
|
||||||
|
preview = render_hairline_overlay(image_bgr, ctx["points"], ext_faces, uv, white)
|
||||||
|
images.append({"hairline_type": key, "order": order, "image_bgr": preview})
|
||||||
|
if order == 1: # 最佳发际线曲线的中点(面部中轴处的发际线 y)
|
||||||
|
overlay = build_overlay_layer(h, w, ctx["points"], ext_faces, uv, white)
|
||||||
|
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}
|
||||||
|
|
||||||
|
|
||||||
|
def generate_grow_b(marked_bgr: np.ndarray, original_bgr: np.ndarray):
|
||||||
|
"""接口3:检测医生手绘发际线 → 遮罩 → 原图重画干净线 → ComfyUI 生发。
|
||||||
|
|
||||||
|
Returns: {"grown_png": bytes 或 None, "status": "ok"|"no_face"|"no_line"}。
|
||||||
|
"""
|
||||||
|
rgb = cv2.cvtColor(marked_bgr, cv2.COLOR_BGR2RGB)
|
||||||
|
landmarks = get_landmarker().detect(rgb)
|
||||||
|
if landmarks is None:
|
||||||
|
return {"grown_png": None, "status": "no_face"}
|
||||||
|
parse_map = get_parser().parse(rgb)
|
||||||
|
path = detect_marker_hairline(marked_bgr, landmarks, parse_map)
|
||||||
|
if path is None:
|
||||||
|
return {"grown_png": None, "status": "no_line"}
|
||||||
|
|
||||||
|
h, w = marked_bgr.shape[:2]
|
||||||
|
# 原图对齐到 marked 坐标系(同一张照片的原始版/划线版,尺寸应一致)
|
||||||
|
orig = original_bgr
|
||||||
|
if orig.shape[:2] != (h, w):
|
||||||
|
orig = cv2.resize(orig, (w, h), interpolation=cv2.INTER_AREA)
|
||||||
|
|
||||||
|
# 在原图上重画干净黑线(膨胀成笔迹宽度),替代医生手绘的毛刺
|
||||||
|
line_w = max(2, int(w * 0.006))
|
||||||
|
marked_clean = orig.copy()
|
||||||
|
cv2.polylines(marked_clean, [path[:, ::-1].reshape(-1, 1, 2)], False,
|
||||||
|
(0, 0, 0), line_w, lineType=cv2.LINE_AA)
|
||||||
|
|
||||||
|
# 遮罩:检测路径曲线 + ROI 闭合
|
||||||
|
curve_mask = path_to_curve_mask(path, h, w, thickness=max(3, line_w))
|
||||||
|
mask = mask_from_curve(curve_mask, landmarks, parse_map)
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
compose_comfy_rgba(marked_clean, mask).save(buf, format="PNG")
|
||||||
|
grown_png = comfyui.run(buf.getvalue())
|
||||||
|
return {"grown_png": grown_png, "status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import sys
|
import sys
|
||||||
g = sys.argv[2] if len(sys.argv) > 2 else "female"
|
g = sys.argv[2] if len(sys.argv) > 2 else "female"
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
@@ -22,5 +22,9 @@ torchvision==0.17.2
|
|||||||
# MediaPipe Tasks(FaceLandmarker) 用已装的 mediapipe;新增 SegFormer 人脸分割:
|
# MediaPipe Tasks(FaceLandmarker) 用已装的 mediapipe;新增 SegFormer 人脸分割:
|
||||||
transformers==4.45.2 # SegFormer 人脸分割(jonathandinu/face-parsing,本地权重)
|
transformers==4.45.2 # SegFormer 人脸分割(jonathandinu/face-parsing,本地权重)
|
||||||
|
|
||||||
|
# 接口3:B端生发(马克笔发际线检测)
|
||||||
|
# ⚠️ 必须 0.24.x —— 0.25+ 强依赖 numpy>=2,会顶掉 mediapipe 需要的 numpy<2
|
||||||
|
scikit-image==0.24.0 # route_through_array(黑帽响应图上的 Dijkstra 最小路径)
|
||||||
|
|
||||||
# 测试
|
# 测试
|
||||||
pytest==8.3.3
|
pytest==8.3.3
|
||||||
|
|||||||
|
After Width: | Height: | Size: 253 KiB |
@@ -81,7 +81,16 @@ def test_grow_missing_gender_1004(client):
|
|||||||
assert r.json()["code"] == 1004
|
assert r.json()["code"] == 1004
|
||||||
|
|
||||||
|
|
||||||
def test_grow_female_returns_5(client):
|
_PNG_1x1 = base64.b64decode(
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_grow_female_returns_5(client, monkeypatch):
|
||||||
|
# mock ComfyUI:不依赖 8182、不跑 Flux,只验证管线接线 + grown 字段
|
||||||
|
import hairline.comfyui as comfy
|
||||||
|
monkeypatch.setattr(comfy, "run", lambda *a, **k: _PNG_1x1)
|
||||||
|
|
||||||
files = {"image_file": ("frontal.jpg", open(fixture("frontal.jpg"), "rb"), "application/octet-stream")}
|
files = {"image_file": ("frontal.jpg", open(fixture("frontal.jpg"), "rb"), "application/octet-stream")}
|
||||||
r = client.post(GROW, headers=H, files=files, data={"gender": "female"})
|
r = client.post(GROW, headers=H, files=files, data={"gender": "female"})
|
||||||
body = r.json()
|
body = r.json()
|
||||||
@@ -90,9 +99,63 @@ def test_grow_female_returns_5(client):
|
|||||||
assert [x["hairline_type"] for x in results] == ["ellipse", "flower", "heart", "straight", "wave"]
|
assert [x["hairline_type"] for x in results] == ["ellipse", "flower", "heart", "straight", "wave"]
|
||||||
assert [x["order"] for x in results] == [1, 2, 3, 4, 5]
|
assert [x["order"] for x in results] == [1, 2, 3, 4, 5]
|
||||||
assert base64.b64decode(results[0]["image_base64"])[:8] == b"\x89PNG\r\n\x1a\n"
|
assert base64.b64decode(results[0]["image_base64"])[:8] == b"\x89PNG\r\n\x1a\n"
|
||||||
|
assert base64.b64decode(results[0]["grown_image_base64"])[:8] == b"\x89PNG\r\n\x1a\n"
|
||||||
assert "image_url" not in results[0]
|
assert "image_url" not in results[0]
|
||||||
|
|
||||||
|
|
||||||
|
GROWB = "/api/v1/hair/grow-b"
|
||||||
|
|
||||||
|
|
||||||
|
def test_growb_missing_original_1007(client):
|
||||||
|
files = {"marked_image_file": ("m.jpg", open(fixture("marked_hairline.jpg"), "rb"), "application/octet-stream")}
|
||||||
|
r = client.post(GROWB, headers=H, files=files)
|
||||||
|
assert r.json()["code"] == 1007
|
||||||
|
|
||||||
|
|
||||||
|
def test_growb_no_line_1001(client):
|
||||||
|
f = lambda: open(fixture("frontal.jpg"), "rb")
|
||||||
|
files = {"marked_image_file": ("m.jpg", f(), "application/octet-stream"),
|
||||||
|
"original_image_file": ("o.jpg", f(), "application/octet-stream")}
|
||||||
|
r = client.post(GROWB, headers=H, files=files)
|
||||||
|
assert r.json()["code"] == 1001
|
||||||
|
|
||||||
|
|
||||||
|
def test_growb_success(client, monkeypatch):
|
||||||
|
import hairline.comfyui as comfy
|
||||||
|
monkeypatch.setattr(comfy, "run", lambda *a, **k: _PNG_1x1)
|
||||||
|
fm = open(fixture("marked_hairline.jpg"), "rb")
|
||||||
|
fo = open(fixture("marked_hairline.jpg"), "rb")
|
||||||
|
files = {"marked_image_file": ("m.jpg", fm, "application/octet-stream"),
|
||||||
|
"original_image_file": ("o.jpg", fo, "application/octet-stream")}
|
||||||
|
body = client.post(GROWB, headers=H, files=files).json()
|
||||||
|
assert body["code"] == 0, body
|
||||||
|
d = body["data"]
|
||||||
|
assert d["hairline_type"] == "custom"
|
||||||
|
assert base64.b64decode(d["hair_growth_image_base64"])[:8] == b"\x89PNG\r\n\x1a\n"
|
||||||
|
assert d["best_hairline_image_base64"] # 原图原样(非空)
|
||||||
|
assert "best_hairline_image_url" not in d
|
||||||
|
|
||||||
|
|
||||||
|
HLGEN = "/api/v1/hairline/generate"
|
||||||
|
|
||||||
|
|
||||||
|
def test_hairline_gen_missing_gender_1004(client):
|
||||||
|
files = {"image_file": ("frontal.jpg", open(fixture("frontal.jpg"), "rb"), "application/octet-stream")}
|
||||||
|
assert client.post(HLGEN, headers=H, files=files).json()["code"] == 1004
|
||||||
|
|
||||||
|
|
||||||
|
def test_hairline_gen_female(client):
|
||||||
|
files = {"image_file": ("frontal.jpg", open(fixture("frontal.jpg"), "rb"), "application/octet-stream")}
|
||||||
|
body = client.post(HLGEN, headers=H, files=files, data={"gender": "female"}).json()
|
||||||
|
assert body["code"] == 0, body
|
||||||
|
d = body["data"]
|
||||||
|
assert [x["order"] for x in d["hairline_images"]] == [1, 2, 3, 4, 5]
|
||||||
|
assert base64.b64decode(d["hairline_images"][0]["image_base64"])[:8] == b"\x89PNG\r\n\x1a\n"
|
||||||
|
c = d["best_hairline_center_point"]
|
||||||
|
assert 0 <= c["x"] <= 682 and 0 <= c["y"] <= 811 # 落在原图范围内
|
||||||
|
assert "image_url" not in d["hairline_images"][0]
|
||||||
|
|
||||||
|
|
||||||
def test_success_structure(client):
|
def test_success_structure(client):
|
||||||
r = _post(client, "frontal.jpg")
|
r = _post(client, "frontal.jpg")
|
||||||
body = r.json()
|
body = r.json()
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""接口3 马克笔检测测试:辅助函数(纯numpy) + 真实样本检测/拒识。"""
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
from conftest import fixture
|
||||||
|
|
||||||
|
from hairline.marker_detect import (
|
||||||
|
detect_marker_hairline, path_to_curve_mask, _snap_anchor,
|
||||||
|
)
|
||||||
|
from hairline.service import get_landmarker, get_parser
|
||||||
|
|
||||||
|
|
||||||
|
def test_path_to_curve_mask():
|
||||||
|
path = np.array([[10, 5], [10, 50], [10, 90]]) # 水平线 row=10
|
||||||
|
m = path_to_curve_mask(path, 100, 100, thickness=3)
|
||||||
|
assert m[10, 50] == 255
|
||||||
|
assert m[90, 50] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_snap_anchor_moves_to_peak():
|
||||||
|
bh = np.zeros((100, 100), np.float32)
|
||||||
|
bh[40, 30] = 99.0 # 峰值在 (40,30)
|
||||||
|
r, c = _snap_anchor(bh, x=33, y=42, w=1000) # 起点附近 → 应吸到峰值
|
||||||
|
assert (r, c) == (40, 30)
|
||||||
|
|
||||||
|
|
||||||
|
def _ctx(path_img):
|
||||||
|
img = cv2.imread(path_img)
|
||||||
|
rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
|
||||||
|
lm = get_landmarker().detect(rgb)
|
||||||
|
pm = get_parser().parse(rgb)
|
||||||
|
return img, lm, pm
|
||||||
|
|
||||||
|
|
||||||
|
def test_detect_real_marked():
|
||||||
|
img, lm, pm = _ctx(fixture("marked_hairline.jpg"))
|
||||||
|
path = detect_marker_hairline(img, lm, pm)
|
||||||
|
assert path is not None and len(path) > 50 # 检测到画线
|
||||||
|
|
||||||
|
|
||||||
|
def test_reject_clean_photo():
|
||||||
|
img, lm, pm = _ctx(fixture("frontal.jpg"))
|
||||||
|
assert detect_marker_hairline(img, lm, pm) is None # 无画线 → 拒识
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""接口2 第二步 遮罩几何单元测试(纯 numpy/cv2,无需 SegFormer/torch)。"""
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from hairline.mask import (
|
||||||
|
forehead_upper_region, head_silhouette, _above_curve_region,
|
||||||
|
compose_comfy_rgba, FOREHEAD_LANDMARKS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_forehead_upper_region():
|
||||||
|
w = h = 200
|
||||||
|
lm = np.zeros((468, 3), np.float32)
|
||||||
|
for i in FOREHEAD_LANDMARKS: # 额头边界点都放在 y=0.4
|
||||||
|
lm[i, 0] = 0.5
|
||||||
|
lm[i, 1] = 0.4
|
||||||
|
m = forehead_upper_region(lm, w, h)
|
||||||
|
assert m[10, 100] == 255 # 上方在区域内
|
||||||
|
assert m[150, 100] == 0 # 下方(0.75h)在区域外
|
||||||
|
|
||||||
|
|
||||||
|
def test_head_silhouette():
|
||||||
|
pm = np.zeros((50, 50), np.int32)
|
||||||
|
pm[:25] = 13 # hair
|
||||||
|
pm[25:40] = 1 # skin
|
||||||
|
pm[40:] = 0 # bg
|
||||||
|
m = head_silhouette(pm)
|
||||||
|
assert m[10, 10] == 255 and m[30, 10] == 255 # hair/skin → 头部
|
||||||
|
assert m[45, 10] == 0 # bg → 非头部
|
||||||
|
|
||||||
|
|
||||||
|
def test_above_curve_region():
|
||||||
|
h = w = 100
|
||||||
|
curve = np.zeros((h, w), np.uint8)
|
||||||
|
curve[60, 20:80] = 255 # 水平发际线在 row=60
|
||||||
|
above = _above_curve_region(curve, h, w)
|
||||||
|
assert above[30, 50] == 255 # 线以上
|
||||||
|
assert above[80, 50] == 0 # 线以下
|
||||||
|
|
||||||
|
|
||||||
|
def test_compose_comfy_rgba_alpha():
|
||||||
|
marked = np.zeros((10, 10, 3), np.uint8)
|
||||||
|
mask = np.zeros((10, 10), np.uint8)
|
||||||
|
mask[2:5, 2:5] = 255 # 重绘区
|
||||||
|
rgba = np.array(compose_comfy_rgba(marked, mask))
|
||||||
|
assert rgba.shape == (10, 10, 4)
|
||||||
|
assert rgba[3, 3, 3] == 0 # 重绘区 alpha=0(透明)
|
||||||
|
assert rgba[8, 8, 3] == 255 # 非重绘区 alpha=255(不透明)
|
||||||