This commit is contained in:
2026-08-12 17:27:17 +08:00
parent a7b7833e2b
commit 350223c5ad
4 changed files with 0 additions and 635 deletions
View File
-89
View File
@@ -1,89 +0,0 @@
# 人脸裁剪与背景去除
`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.9GPU 可选,语义分割默认自动选择 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
```
-537
View File
@@ -1,537 +0,0 @@
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())
-9
View File
@@ -1,9 +0,0 @@
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