From 61c9939753bb3314966f8e6cace54e0dfc10bf83 Mon Sep 17 00:00:00 2001 From: YULI <1575108428@qq.com> Date: Wed, 12 Aug 2026 16:38:10 +0800 Subject: [PATCH] first community --- README.md | 89 ++++++ extract_head_mask_single.py | 537 ++++++++++++++++++++++++++++++++++++ requirements.txt | 9 + 3 files changed, 635 insertions(+) create mode 100644 README.md create mode 100644 extract_head_mask_single.py create mode 100644 requirements.txt diff --git a/README.md b/README.md new file mode 100644 index 0000000..521f0c3 --- /dev/null +++ b/README.md @@ -0,0 +1,89 @@ +# 人脸裁剪与背景去除 + +`extract_faces.py` 会批量检测正脸及左右侧脸,按 MediaPipe 三维人脸网格生成面部轮廓,再用 GrabCut 细化边缘。结果保存为带透明通道的 PNG,只保留面部区域,不包含头发、耳朵和衣服。 + +## 运行 + +```powershell +python -m pip install -r requirements.txt +python extract_faces.py . --output faces_no_bg +``` + +也可以处理单张图片: + +```powershell +python extract_faces.py yolo_white_left.jpg --output faces_no_bg +``` + +## 常用参数 + +- `--padding 0.04`:人脸周围透明边距比例。 +- `--expand 0.004`:向外扩展面部蒙版,避免切掉边缘皮肤。 +- `--feather 0.002`:透明边缘羽化比例。 +- `--edge-band 0.05`:覆盖自动姿态估计,手动指定轮廓细化带宽度。 +- `--max-refine-size 960`:轮廓细化使用的最大局部图尺寸。 +- `--no-edge-refine`:关闭 GrabCut,仅使用人脸网格轮廓。 +- `--recursive`:递归处理输入目录中的图片。 +- `--rotate-left`:检测前将每张输入图逆时针旋转 90 度,不修改原图。 + +原图不会被覆盖,输出文件名格式为 `<原文件名>_face.png`。 + +## 仅保留面部皮肤 + +`extract_face_skin.py` 使用语义分割保留皮肤、鼻子、眼睛、眉毛和嘴唇,排除头发、耳朵、颈部与背景。第一次运行会下载通用预训练模型。 + +```powershell +python extract_face_skin.py "输入目录" --output "输出目录" --recursive --rotate-left +``` + +默认按模型训练分辨率 `512×512` 做语义分割。 + +## 保留头发并输出 Mask + +`extract_head_mask.py` 先根据人脸位置和朝向自适应扩展发顶、两侧与后脑区域,再分割脸、耳朵和头发。每张输入图会得到: + +- `<原名>_mask.png`:与旋转后原图等大的灰度 Alpha Mask。 +- `<原名>_head.png`:使用平滑 Mask 截取的黑色背景 RGB PNG。 + +```powershell +python extract_head_mask.py "输入目录" --output "输出目录" --recursive --rotate-left +``` + +输入根目录含有其它结果文件夹时,可以重复使用 `--exclude-dir` 排除: + +```powershell +python extract_head_mask.py "输入目录" --output "输出目录" --recursive --rotate-left --exclude-dir faces_no_bg --exclude-dir output +``` + +如需只分发一个业务脚本,可使用功能相同且不导入其它本地脚本的单文件版: + +```powershell +python -m pip install -r requirements-head-mask.txt +python extract_head_mask_single.py "输入目录" --output "输出目录" --recursive --rotate-left +``` + +最小分发内容为 `extract_head_mask_single.py`、`requirements-head-mask.txt` 和本说明文档。建议使用 Windows 64 位及 Python 3.9;GPU 可选,语义分割默认自动选择 CUDA 或 CPU,人脸检测固定使用 CPU。 + +## 模型下载与离线运行 + +首次运行会下载以下两个模型,模型文件不会嵌入 Python 脚本: + +- 语义分割模型:[`jonathandinu/face-parsing`](https://huggingface.co/jonathandinu/face-parsing),默认缓存到 `%USERPROFILE%\.cache\huggingface\hub\models--jonathandinu--face-parsing`。 +- 人脸检测模型:[`buffalo_l.zip`](https://github.com/deepinsight/insightface/releases/download/v0.7/buffalo_l.zip),默认解压到 `%USERPROFILE%\.insightface\models\buffalo_l`,实际检测文件是 `det_10g.onnx`。 + +离线分发时,在联网电脑上先运行一次脚本,再准备以下文件: + +1. 将 Hugging Face 缓存中同一 `snapshots\<版本>` 目录内的 `config.json`、`preprocessor_config.json` 和 `model.safetensors` 复制到分发目录的 `models\face-parsing`。 +2. 将整个 `buffalo_l` 目录复制到离线电脑的 `%USERPROFILE%\.insightface\models\buffalo_l`,确保 `det_10g.onnx` 直接位于该目录内。 +3. 使用本地语义模型运行: + +```powershell +python extract_head_mask_single.py "输入目录" --output "输出目录" --recursive --rotate-left --model ".\models\face-parsing" +``` + +Python 第三方依赖也需要预先安装。可在相同 Windows/Python 环境的联网电脑下载离线安装包: + +```powershell +python -m pip download -r requirements-head-mask.txt --dest wheels +python -m pip install --no-index --find-links .\wheels -r requirements-head-mask.txt +``` diff --git a/extract_head_mask_single.py b/extract_head_mask_single.py new file mode 100644 index 0000000..a4551e1 --- /dev/null +++ b/extract_head_mask_single.py @@ -0,0 +1,537 @@ +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +import cv2 +import numpy as np +import torch +import torch.nn.functional as torch_functional +from insightface.app import FaceAnalysis +from PIL import Image, ImageOps +from transformers import AutoImageProcessor, AutoModelForSemanticSegmentation + + +SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".bmp", ".webp", ".tif", ".tiff"} +DEFAULT_MODEL = "jonathandinu/face-parsing" +INSIGHTFACE_MODEL = "buffalo_l" +HEAD_LABELS = { + "skin", + "nose", + "eye_g", + "l_eye", + "r_eye", + "l_brow", + "r_brow", + "mouth", + "u_lip", + "l_lip", + "l_ear", + "r_ear", + "hair", +} + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Extract complete heads to black-background PNG files and grayscale masks." + ) + parser.add_argument("input", nargs="?", default=".", help="Input image or directory.") + parser.add_argument("-o", "--output", default="heads_no_bg", help="Output directory.") + parser.add_argument("--recursive", action="store_true", help="Search directories recursively.") + parser.add_argument( + "--exclude-dir", + action="append", + default=[], + help="Directory name to skip while searching; may be supplied more than once.", + ) + parser.add_argument( + "--rotate-left", + action="store_true", + help="Rotate each image 90 degrees counterclockwise before processing.", + ) + parser.add_argument( + "--save-mask", + action=argparse.BooleanOptionalAction, + default=True, + help="Save the full-resolution grayscale mask (default: enabled).", + ) + parser.add_argument( + "--padding", + type=float, + default=0.04, + help="Black crop padding as a fraction of head size (default: 0.04).", + ) + parser.add_argument( + "--expand", + type=float, + default=0.002, + help="Mask expansion as a fraction of head size (default: 0.002).", + ) + parser.add_argument( + "--feather", + type=float, + default=0.002, + help="Edge feathering as a fraction of head size (default: 0.002).", + ) + parser.add_argument("--model", default=DEFAULT_MODEL, help="Hugging Face face-parsing model ID.") + parser.add_argument( + "--inference-size", + type=int, + default=512, + help="Square semantic inference size (default: 512).", + ) + parser.add_argument( + "--device", + choices=("auto", "cpu", "cuda"), + default="auto", + help="Inference device (default: auto).", + ) + return parser.parse_args() + + +def validate_args(args: argparse.Namespace) -> None: + for name in ("padding", "expand", "feather"): + if getattr(args, name) < 0: + raise ValueError(f"--{name} must be non-negative") + if args.device == "cuda" and not torch.cuda.is_available(): + raise ValueError("CUDA was requested but is not available") + if args.inference_size < 256: + raise ValueError("--inference-size must be at least 256") + + +def find_images( + input_path: Path, + output_dir: Path, + recursive: bool, + excluded_dirs: list[str], +) -> list[Path]: + if input_path.is_file(): + if input_path.suffix.lower() not in SUPPORTED_EXTENSIONS: + raise ValueError(f"Unsupported image format: {input_path.suffix}") + return [input_path] + if not input_path.is_dir(): + raise FileNotFoundError(f"Input does not exist: {input_path}") + + iterator = input_path.rglob("*") if recursive else input_path.glob("*") + output_resolved = output_dir.resolve() + excluded = {name.casefold() for name in excluded_dirs} + images = [] + for path in iterator: + if not path.is_file() or path.suffix.lower() not in SUPPORTED_EXTENSIONS: + continue + relative_parts = path.relative_to(input_path).parts[:-1] + if any(part.casefold() in excluded for part in relative_parts): + continue + try: + path.resolve().relative_to(output_resolved) + continue + except ValueError: + images.append(path) + return sorted(images, key=lambda path: str(path).lower()) + + +def load_rgb(path: Path, rotate_left: bool) -> np.ndarray: + with Image.open(path) as image: + rgb = np.asarray(ImageOps.exif_transpose(image).convert("RGB")) + if rotate_left: + rgb = np.rot90(rgb) + return np.ascontiguousarray(rgb) + + +def create_detector() -> FaceAnalysis: + detector = FaceAnalysis( + name=INSIGHTFACE_MODEL, + allowed_modules=["detection"], + providers=["CPUExecutionProvider"], + ) + detector.prepare(ctx_id=-1, det_size=(640, 640), det_thresh=0.15) + return detector + + +def largest_head_roi( + detector: FaceAnalysis, + rgb: np.ndarray, +) -> tuple[tuple[int, int, int, int], tuple[int, int, int, int], float]: + height, width = rgb.shape[:2] + faces = detector.get(cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR)) + if not faces: + fallback_face = ( + round(width * 0.2), + round(height * 0.15), + round(width * 0.8), + round(height * 0.9), + ) + return (0, 0, width, height), fallback_face, float(max(width, height) * 0.7) + + face = max( + faces, + key=lambda item: float((item.bbox[2] - item.bbox[0]) * (item.bbox[3] - item.bbox[1])), + ) + left, top, right, bottom = (float(value) for value in face.bbox) + face_width = right - left + face_height = bottom - top + face_size = max(face_width, face_height) + face_box = ( + max(0, int(np.floor(left))), + max(0, int(np.floor(top))), + min(width, int(np.ceil(right))), + min(height, int(np.ceil(bottom))), + ) + + center_x = (left + right) * 0.5 + nose_x = center_x + if getattr(face, "kps", None) is not None and len(face.kps) >= 3: + nose_x = float(face.kps[2, 0]) + yaw = float(np.clip((nose_x - center_x) / max(face_width * 0.5, 1.0), -1.0, 1.0)) + side_pose = abs(yaw) + front_margin = 0.2 + 0.05 * side_pose + back_margin = 0.3 + 0.2 * side_pose + if yaw > 0: + left_margin, right_margin = back_margin, front_margin + elif yaw < 0: + left_margin, right_margin = front_margin, back_margin + else: + left_margin = right_margin = 0.3 + roi = ( + max(0, int(np.floor(left - face_width * left_margin))), + max(0, int(np.floor(top - face_height * 0.5))), + min(width, int(np.ceil(right + face_width * right_margin))), + min(height, int(np.ceil(bottom + face_height * 0.1))), + ) + return roi, face_box, face_size + + +def semantic_label_ids(model: AutoModelForSemanticSegmentation) -> list[int]: + ids = [ + int(label_id) + for label_id, label_name in model.config.id2label.items() + if str(label_name).lower() in HEAD_LABELS + ] + if not ids: + raise ValueError("The segmentation model does not expose supported head labels") + return ids + + +def segment_head( + rgb: np.ndarray, + detector: FaceAnalysis, + processor: AutoImageProcessor, + model: AutoModelForSemanticSegmentation, + label_ids: list[int], + device: torch.device, + inference_size: int, +) -> np.ndarray: + (left, top, right, bottom), face_box, face_size = largest_head_roi(detector, rgb) + roi = rgb[top:bottom, left:right] + inputs = processor( + images=Image.fromarray(roi), + return_tensors="pt", + size={"height": inference_size, "width": inference_size}, + ) + inputs = {name: tensor.to(device) for name, tensor in inputs.items()} + with torch.inference_mode(): + logits = model(**inputs).logits + logits = torch_functional.interpolate( + logits, + size=roi.shape[:2], + mode="bilinear", + align_corners=False, + ) + labels = logits.argmax(dim=1)[0].cpu().numpy() + + roi_mask = np.isin(labels, label_ids).astype(np.uint8) * 255 + ear_label_ids = [ + int(label_id) + for label_id, label_name in model.config.id2label.items() + if str(label_name).lower() in {"l_ear", "r_ear"} + ] + local_face_box = ( + face_box[0] - left, + face_box[1] - top, + face_box[2] - left, + face_box[3] - top, + ) + roi_mask = remove_posterior_ear_artifacts( + roi_mask, + labels, + ear_label_ids, + local_face_box, + face_size, + ) + mask = np.zeros(rgb.shape[:2], dtype=np.uint8) + mask[top:bottom, left:right] = roi_mask + return clean_head_mask(mask, face_box, face_size) + + +def remove_posterior_ear_artifacts( + mask: np.ndarray, + labels: np.ndarray, + ear_label_ids: list[int], + face_box: tuple[int, int, int, int], + face_size: float, +) -> np.ndarray: + if not ear_label_ids: + return mask + + ear_count, _, ear_stats, _ = cv2.connectedComponentsWithStats( + np.isin(labels, ear_label_ids).astype(np.uint8), + connectivity=8, + ) + if ear_count <= 1: + return mask + + face_left, _, face_right, _ = face_box + face_width = max(1, face_right - face_left) + face_center = (face_left + face_right) * 0.5 + minimum_ear_area = max(16, round(face_size * face_size * 0.0005)) + maximum_artifact_area = max(64, round(face_size * face_size * 0.04)) + maximum_artifact_width = max(8, round(face_size * 0.22)) + side_margin = round(face_size * 0.02) + cleaned = mask.copy() + + for ear_label in range(1, ear_count): + ear_area = int(ear_stats[ear_label, cv2.CC_STAT_AREA]) + if ear_area < minimum_ear_area: + continue + + ear_left = int(ear_stats[ear_label, cv2.CC_STAT_LEFT]) + ear_top = int(ear_stats[ear_label, cv2.CC_STAT_TOP]) + ear_width = int(ear_stats[ear_label, cv2.CC_STAT_WIDTH]) + ear_height = int(ear_stats[ear_label, cv2.CC_STAT_HEIGHT]) + ear_right = ear_left + ear_width + ear_bottom = ear_top + ear_height + ear_center = (ear_left + ear_right) * 0.5 + if abs(ear_center - face_center) < face_width * 0.35 or ear_bottom >= mask.shape[0]: + continue + + lower_mask = np.where(cleaned[ear_bottom:] > 0, 1, 0).astype(np.uint8) + component_count, components, stats, _ = cv2.connectedComponentsWithStats( + lower_mask, + connectivity=8, + ) + for component in range(1, component_count): + component_left = int(stats[component, cv2.CC_STAT_LEFT]) + component_width = int(stats[component, cv2.CC_STAT_WIDTH]) + component_right = component_left + component_width + component_area = int(stats[component, cv2.CC_STAT_AREA]) + if component_width > maximum_artifact_width or component_area > maximum_artifact_area: + continue + + if ear_center > face_center: + is_posterior = component_left >= max(face_right, ear_left) - side_margin + else: + is_posterior = component_right <= min(face_left, ear_right) + side_margin + if is_posterior: + cleaned[ear_bottom:][components == component] = 0 + + return cleaned + + +def clean_head_mask( + mask: np.ndarray, + face_box: tuple[int, int, int, int], + face_size: float, +) -> np.ndarray: + bridge = max(1, round(face_size * 0.008)) + kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (bridge * 2 + 1, bridge * 2 + 1)) + mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel) + + component_count, labels, stats, _ = cv2.connectedComponentsWithStats( + np.where(mask > 0, 1, 0).astype(np.uint8), + connectivity=8, + ) + if component_count <= 1: + raise ValueError("The semantic model did not find a head") + + face_left, face_top, face_right, face_bottom = face_box + face_labels = labels[face_top:face_bottom, face_left:face_right] + best_label = 0 + best_score = -1.0 + for label in range(1, component_count): + overlap = int(np.count_nonzero(face_labels == label)) + area = int(stats[label, cv2.CC_STAT_AREA]) + score = overlap * 10.0 + area + if score > best_score: + best_label = label + best_score = score + cleaned = np.where(labels == best_label, 255, 0).astype(np.uint8) + + # Preserve semantic gaps between the ear, hair and face. Filling the full + # outer contour would also keep equipment enclosed by the head outline. + hole_limit = max(16, round(face_size * face_size * 0.00015)) + inverted = cv2.bitwise_not(cleaned) + hole_count, hole_labels, hole_stats, _ = cv2.connectedComponentsWithStats( + np.where(inverted > 0, 1, 0).astype(np.uint8), + connectivity=8, + ) + for label in range(1, hole_count): + x = int(hole_stats[label, cv2.CC_STAT_LEFT]) + y = int(hole_stats[label, cv2.CC_STAT_TOP]) + width = int(hole_stats[label, cv2.CC_STAT_WIDTH]) + height = int(hole_stats[label, cv2.CC_STAT_HEIGHT]) + area = int(hole_stats[label, cv2.CC_STAT_AREA]) + touches_border = ( + x == 0 + or y == 0 + or x + width == cleaned.shape[1] + or y + height == cleaned.shape[0] + ) + if not touches_border and area <= hole_limit: + cleaned[hole_labels == label] = 255 + return cleaned + + +def finish_mask(mask: np.ndarray, expand_ratio: float, feather_ratio: float) -> np.ndarray: + rows, columns = np.nonzero(mask) + if not len(columns): + raise ValueError("Generated head mask is empty") + head_size = max(int(columns.max() - columns.min() + 1), int(rows.max() - rows.min() + 1)) + + expand_pixels = round(head_size * expand_ratio) + if expand_pixels > 0: + size = expand_pixels * 2 + 1 + kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (size, size)) + mask = cv2.dilate(mask, kernel) + + smoothing = max(1, round(head_size * 0.0015)) + size = smoothing * 2 + 1 + kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (size, size)) + mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel) + mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel) + + feather_pixels = head_size * feather_ratio + if feather_pixels >= 0.5: + mask = cv2.GaussianBlur(mask, (0, 0), feather_pixels, feather_pixels) + return mask + + +def crop_rgba(rgb: np.ndarray, alpha: np.ndarray, padding_ratio: float) -> np.ndarray: + rows, columns = np.nonzero(alpha > 1) + if not len(columns): + raise ValueError("Generated head mask is empty") + + left = int(columns.min()) + right = int(columns.max()) + 1 + top = int(rows.min()) + bottom = int(rows.max()) + 1 + head_size = max(right - left, bottom - top) + padding = round(head_size * padding_ratio) + desired_left = left - padding + desired_top = top - padding + desired_right = right + padding + desired_bottom = bottom + padding + + source_left = max(0, desired_left) + source_top = max(0, desired_top) + source_right = min(rgb.shape[1], desired_right) + source_bottom = min(rgb.shape[0], desired_bottom) + output = np.zeros( + (desired_bottom - desired_top, desired_right - desired_left, 4), + dtype=np.uint8, + ) + offset_x = source_left - desired_left + offset_y = source_top - desired_top + copy_height = source_bottom - source_top + copy_width = source_right - source_left + output[offset_y : offset_y + copy_height, offset_x : offset_x + copy_width, :3] = rgb[ + source_top:source_bottom, source_left:source_right + ] + output[offset_y : offset_y + copy_height, offset_x : offset_x + copy_width, 3] = alpha[ + source_top:source_bottom, source_left:source_right + ] + output[output[:, :, 3] == 0, :3] = 0 + return output + + +def composite_on_black(rgba: np.ndarray) -> np.ndarray: + alpha = rgba[:, :, 3:4].astype(np.uint16) + return ((rgba[:, :, :3].astype(np.uint16) * alpha + 127) // 255).astype(np.uint8) + + +def output_paths_for( + image_path: Path, + input_path: Path, + output_dir: Path, +) -> tuple[Path, Path]: + if input_path.is_dir(): + try: + relative_parent = image_path.parent.relative_to(input_path) + except ValueError: + relative_parent = Path() + else: + relative_parent = Path() + parent = output_dir / relative_parent + return parent / f"{image_path.stem}_head.png", parent / f"{image_path.stem}_mask.png" + + +def resolve_device(name: str) -> torch.device: + if name == "cuda" or (name == "auto" and torch.cuda.is_available()): + return torch.device("cuda") + return torch.device("cpu") + + +def process_images(args: argparse.Namespace) -> int: + input_path = Path(args.input).expanduser() + output_dir = Path(args.output).expanduser() + images = find_images(input_path, output_dir, args.recursive, args.exclude_dir) + if not images: + print("No supported images found.", file=sys.stderr) + return 1 + + output_dir.mkdir(parents=True, exist_ok=True) + device = resolve_device(args.device) + print(f"Loading semantic head parser '{args.model}' on {device}...") + processor = AutoImageProcessor.from_pretrained(args.model, use_fast=False) + model = AutoModelForSemanticSegmentation.from_pretrained(args.model).to(device).eval() + label_ids = semantic_label_ids(model) + print(f"Loading InsightFace detector '{INSIGHTFACE_MODEL}' on CPU...") + detector = create_detector() + + failures = 0 + for image_path in images: + try: + rgb = load_rgb(image_path, args.rotate_left) + mask = segment_head( + rgb, + detector, + processor, + model, + label_ids, + device, + args.inference_size, + ) + alpha = finish_mask(mask, args.expand, args.feather) + output_image = composite_on_black(crop_rgba(rgb, alpha, args.padding)) + destination, mask_destination = output_paths_for(image_path, input_path, output_dir) + destination.parent.mkdir(parents=True, exist_ok=True) + if args.save_mask: + Image.fromarray(alpha).save(mask_destination, optimize=True) + Image.fromarray(output_image).save(destination, optimize=True) + mask_message = f", mask: {mask_destination}" if args.save_mask else "" + print( + f"OK {image_path} -> {destination} " + f"({output_image.shape[1]}x{output_image.shape[0]}{mask_message})" + ) + except Exception as error: + failures += 1 + print(f"FAIL {image_path}: {error}", file=sys.stderr) + + succeeded = len(images) - failures + print(f"Processed {succeeded}/{len(images)} image(s). Output: {output_dir.resolve()}") + return 0 if failures == 0 else 2 + + +def main() -> int: + args = parse_args() + try: + validate_args(args) + return process_images(args) + except (FileNotFoundError, ValueError) as error: + print(f"Error: {error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4c16f91 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +mediapipe==0.10.9 +numpy>=1.24,<2.1 +opencv-python>=4.8 +Pillow>=10.0 +torch>=2.0 +transformers>=4.40,<5 +safetensors>=0.4 +insightface>=0.7 +onnxruntime>=1.16