feat(接口3): B端生发-马克笔发际线检测+生发(替换Mock)
医生在额头用马克笔画规划发际线 → 检测该线 → 生发。检测算法源自 /home/xsl/headmark。 - hairline/marker_detect.py: 黑帽响应图(MORPH_BLACKHAT)+鬓角锚点(MediaPipe 21/251吸附) +skimage route_through_array 最小路径检测画线;路径平均响应阈值拒识无画线 (headmark 调研:全局灰度阈值不可用,黑帽+Dijkstra 实测误差≤0.5px) - hairline/mask.py: 抽出 mask_from_curve(曲线+ROI闭合),接口2/3共用 - hairline/service.py: generate_grow_b——检测→遮罩→原图重画干净线→ComfyUI生发 - app.py: /hair/grow-b 真实实现,marked+original各三选一+校验;输出 best_hairline_image_base64(=原图)/hair_growth_image_base64/hairline_type="custom"; 无人脸或未检测到画线→1001;重活进线程池 - requirements: scikit-image==0.24.0 (⚠️锁0.24,0.25+强依赖numpy>=2会顶掉mediapipe的numpy<2) - 文档: docs/接口3-B端生发-技术实现方案.md - 测试: test_marker.py(检测/拒识/辅助) + test_api grow-b(mock ComfyUI),42全绿 实测(5090): grow-b ~6.4s,生发图把额头发际线补到医生画线、清除划线、人物保持。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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")
|
||||
+15
-13
@@ -83,27 +83,29 @@ def _clean_mask(mask: np.ndarray, w: int) -> np.ndarray:
|
||||
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):
|
||||
"""返回 (marked_bgr 划线图, mask uint8 0..255 重绘区)。"""
|
||||
"""接口2:返回 (marked_bgr 划线图, mask uint8 0..255 重绘区)。"""
|
||||
h, w = photo_bgr.shape[:2]
|
||||
uv, ext_faces = load_ext_mesh()
|
||||
|
||||
# ④ 渲染黑线:marked = 烧进照片;curve_mask = 曲线像素
|
||||
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
|
||||
|
||||
# ①②③ ROI
|
||||
upper = forehead_upper_region(landmarks_mp, w, h)
|
||||
head = head_silhouette(parse_map)
|
||||
roi = cv2.bitwise_and(upper, head)
|
||||
|
||||
# ⑤ ROI ∩ 曲线以上 → 清理
|
||||
above = _above_curve_region(curve_mask, h, w)
|
||||
mask = cv2.bitwise_and(roi, above)
|
||||
mask = _clean_mask(mask, w)
|
||||
mask = mask_from_curve(curve_mask, landmarks_mp, parse_map)
|
||||
return marked, mask
|
||||
|
||||
|
||||
|
||||
+38
-1
@@ -17,7 +17,8 @@ from .face_parsing import FaceParser
|
||||
from .hairline_2d import sample_hairline, smooth_hairline
|
||||
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 .mask import build_inpaint_mask, compose_comfy_rgba
|
||||
from .mask import build_inpaint_mask, compose_comfy_rgba, mask_from_curve
|
||||
from .marker_detect import detect_marker_hairline, path_to_curve_mask
|
||||
|
||||
import io
|
||||
import logging
|
||||
@@ -160,6 +161,42 @@ def generate_grow_results(image_bgr: np.ndarray, gender: str):
|
||||
return results
|
||||
|
||||
|
||||
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__":
|
||||
import sys
|
||||
g = sys.argv[2] if len(sys.argv) > 2 else "female"
|
||||
|
||||
Reference in New Issue
Block a user