简介:这是一份以爬虫、图像识别与生成对抗网络为主线,将写真套图下载、美女脸部识别和DCGAN自动生成面孔整合在一起的Python实战资源,适合具备一定Python基础、想深入GAN应用或人脸图像处理的开发者。压缩包共506个文件,约77.58MB;442张jpg和29张png构成原始图片与生成结果数据集,8个py脚本覆盖数据采集、人脸裁剪、模型训练与生成链路,index/meta/checkpoint等文件提供多阶段DCGAN模型权重,可直接加载继续迭代。已有1737人学习下载。配合md说明、txt列表和批处理脚本,可完整理解从批量抓图、脸部检测对齐到生成新面孔的项目工程组织方式,是一份兼顾代码、数据与训练产物的成套实践资料。
1. 爬虫 + 人脸识别 + DCGAN 的组合:为什么这个资源值得动手跑一遍
先给结论:这个项目不是单纯的爬虫源码包,也不是一个孤立的人脸识别 demo,而是一条完整的「数据采集 → 数据清洗 → 模型训练 → 图像生成」流水线。你需要先写爬虫把写真套图按页拉下来,再用 OpenCV 的人脸检测器把每一张图里的人脸框出来、裁剪成统一尺寸,最后喂给 DCGAN 去生成新的人脸。整个过程踩过一遍,你就能同时摸清 requests 反爬、Haar 级联检测、GAN 训练调参这三块硬骨头,而且每一块的产出都是可视化的——图片下载进度、人脸裁剪预览、生成器输出的假脸,出了问题一眼就能看出来。
这套资源适合两类人:一类是刚学完 Python 基础、想找个综合性项目练手的,另一类是想跑通一次 DCGAN 但手头没有干净人脸数据集的人。前者可以顺着爬虫和人脸裁剪把工程基础补扎实,后者可以跳过第一段直接使用整理好的人脸数据子集来训练模型。但请注意:爬取公开图片应遵守目标站点的 robots 协议和相关法律规定,本项目代码仅限学习交流,不要用于任何商业用途或非法抓取。下面我从爬虫讲起,一步步拆到 DCGAN 生成,每一章的代码都是我试过能跑的版本,参数也是实际调出来的。
2. 写真套图爬虫:请求、解析与断点续传
爬虫在整个项目里的角色是「数据入口」。你要做的不是把整站图片全下下来,而是按栏目分页抓取套图列表,再进入每套图的详情页拿到原图地址。这里最核心的坑不是解析逻辑,而是请求频率和会话保持。直接用 requests.get 裸抓,多半会被站点拦截,原因不是你不带 header,而是缺少 cookie 和 referer 的配合。
2.1 选型与请求头伪装
写爬虫前先选工具链。我一般只用 requests + BeautifulSoup,不用 scrapy,因为套图站的结构不复杂,scrapy 的框架反而让调试变得啰嗦。requests 负责发送 HTTP 请求,BeautifulSoup 用 select 或 find_all 抽取 HTML 里的链接和图片地址。注意,这里不涉及任何绕过访问限制的脚本,只讲常规的请求头伪装和频率控制。
import requests from bs4 import BeautifulSoup import time SESSION = requests.Session() SESSION.headers.update({ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Connection": "keep-alive", }) def fetch_page(url): for attempt in range(3): try: resp = SESSION.get(url, timeout=10) resp.raise_for_status() resp.encoding = resp.apparent_encoding return resp.text except Exception as e: print(f"第 {attempt + 1} 次请求失败: {e}") time.sleep(2) return None这段代码的关键点是requests.Session(),它会自动维持 cookie 状态,很多站点第一次访问会下发一个会话 cookie,后续请求带上它才不会被重定向到验证页。resp.apparent_encoding用于从 HTML 里的 meta 标签推断编码,避免中文乱码导致解析失败。timeout=10是硬性要求,没有超时控制的爬虫一旦遇到挂起的请求,整个队列会卡死。
2.2 页面解析与图片批量下载
拿到 HTML 之后,下一步是解析套图列表和详情页。写真类站点的页面结构通常是:列表页每张缩略图包在<a>标签里,链接指向详情页;详情页里.content img下的src或>from bs4 import BeautifulSoup import os def parse_list(html, base_url): soup = BeautifulSoup(html, "html.parser") detail_links = [] for a in soup.select("a[href*='/detail/']"): href = a.get("href") if href and href not in detail_links: detail_links.append(requests.compat.urljoin(base_url, href)) return detail_links[:20] # 每页只取前20个,避免请求过载 def download_images(detail_html, save_dir): soup = BeautifulSoup(detail_html, "html.parser") img_tags = soup.select(".content img") os.makedirs(save_dir, exist_ok=True) for i, img in enumerate(img_tags): src = img.get("src") or img.get("data-original") if not src: continue ext = src.split("?")[0].split(".")[-1] if ext not in ("jpg", "jpeg", "png"): ext = "jpg" path = os.path.join(save_dir, f"{i:04d}.{ext}") try: with SESSION.get(src, stream=True, timeout=15) as r: r.raise_for_status() with open(path, "wb") as f: for chunk in r.iter_content(chunk_size=8192): f.write(chunk) time.sleep(0.5) except Exception as e: print(f"图片下载失败: {src} -> {e}")
requests.compat.urljoin用来拼接相对地址,很多详情页的链接是相对路径,不拼会直接变成错误 URL。stream=True配合iter_content(chunk_size=8192)是按块写盘,比r.content一次性读入内存更适合下载大图,写真套图的原图普遍在 1MB 以上,一次性读入容易把内存打爆。time.sleep(0.5)是下载间隔,不是每张都等半秒,而是给对端一个缓冲,避免连续高并发触发封 IP。
2.3 使用 sqlalchemy 储存爬虫数据
爬虫一旦跑起来,进度记录就成了刚需。我不建议存 JSON 文件,因为断点续传时你要反复读整个文件再修改,效率低而且容易写坏。用 sqlalchemy 建一张表记录每个详情页的状态,比手写文件靠谱得多。
from sqlalchemy import create_engine, Column, Integer, String, Boolean from sqlalchemy.orm import declarative_base, sessionmaker Base = declarative_base() class CrawlRecord(Base): __tablename__ = "crawl_records" id = Column(Integer, primary_key=True) page_url = Column(String(512), unique=True) status = Column(String(32), default="pending") # pending/downloaded/failed image_count = Column(Integer, default=0) engine = create_engine("sqlite:///crawl_state.db", echo=False) Base.metadata.create_all(engine) SessionLocal = sessionmaker(bind=engine) def mark_done(url, count): with SessionLocal() as session: record = session.query(CrawlRecord).filter_by(page_url=url).first() if record: record.status = "downloaded" record.image_count = count session.commit()用 sqlite 做库是因为单机爬虫不需要网络数据库,sqlite:///crawl_state.db一行就能创建本地文件库。unique=True防止同一详情页被重复爬,配合filter_by(page_url=url).first()能实现天然的去重。注意 sqlalchemy 的 ORM 在写完后要session.commit(),否则数据不会落盘,这是最常见的翻车点——代码不报错,但数据库里永远是空的。
提示:断点续传的核心逻辑就是在
download_images前先查库,如果记录状态是downloaded就跳过该详情页;如果是failed则重新爬,但要把之前下了一半的图片目录清掉,否则会出现图片序号错位。
3. 人脸检测与裁剪:把套图变成可训练的人脸数据集
图片下载只是原始素材,DCGAN 需要的是对齐的人脸图像。这里涉及两个概念:人脸检测是找出图片中「哪一块」是人脸,人脸对齐是把这块人脸归一化到统一尺寸和大致位置。OpenCV 自带的 Haar 级联分类器虽然老,但速度快、不依赖 GPU、安装即用,非常适合批量处理几千张图。dlib 的 68 点模型更精确,但配置繁琐,而且很多人脸被头发遮挡时反而容易失败。我的经验是:先 Haar 粗检,再统一裁剪,最后人工抽查,性价比最高。
3.1 用 OpenCV Haar 级联定位人脸
Haar 级联的原理是滑动窗口 + 多尺度检测,OpenCV 已经把训练好的模型封装成CascadeClassifier。使用时最需要注意的是minSize参数——写真套图里经常有远处的人脸或者半张脸,minSize设太大漏检,设太小会产生大量误检框。
import cv2 haar_path = cv2.data.haarcascades + "haarcascade_frontalface_default.xml" face_cascade = cv2.CascadeClassifier(haar_path) def detect_faces(image_path): img = cv2.imread(image_path) if img is None: return [] gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 缩小图片加速检测,检测框坐标按比例映射回去 scale = 0.5 small_gray = cv2.resize(gray, (0, 0), fx=scale, fy=scale) faces = face_cascade.detectMultiScale( small_gray, scaleFactor=1.1, minNeighbors=5, minSize=(48, 48), ) faces = [(int(x / scale), int(y / scale), int(w / scale), int(h / scale)) for (x, y, w, h) in faces] return facesscaleFactor=1.1是每次缩小窗口比例,越小检测越慢但越准确;minNeighbors=5表示一个区域至少被 5 个相邻窗口命中才算人脸,这个值调低了会框出很多背景纹理,调高了会漏掉侧面脸。我把图片缩到 0.5 倍再检测是因为 Haar 在长宽超 2000 的套图原图上滑窗极慢,缩半张图能提速 4 倍,检测框坐标再乘回去就行。注意cv2.imread读不到文件时返回None,必须提前判断,否则cvtColor直接抛异常导致整个批量程序中断。
3.2 批量裁剪与归一化:缩放填充与保存策略
检测出人脸框之后,不能直接crop就完事。DCGAN 的输入是固定尺寸,比如 64x64 或 128x128,而人脸框是高矮不一的矩形,直接 resize 会把脸拉变形。正确做法是先按中心点把框扩成正方形,然后保持宽高比缩放到目标尺寸,最后用黑色填充不足部分。
import numpy as np import os TARGET_SIZE = 64 def crop_and_resize_face(img, face, target=TARGET_SIZE): x, y, w, h = face cx, cy = x + w // 2, y + h // 2 side = max(w, h) + int(0.2 * max(w, h)) # 多留20%边距 half = side // 2 x0, y0 = max(cx - half, 0), max(cy - half, 0) x1, y1 = min(cx + half, img.shape[1]), min(cy + half, img.shape[0]) face_crop = img[y0:y1, x0:x1] if face_crop.size == 0: return None # 等比例缩放到target-2,再居中放到target* target的画布中 h, w = face_crop.shape[:2] scale = (target - 2) / max(h, w) new_h, new_w = int(h * scale), int(w * scale) resized = cv2.resize(face_crop, (new_w, new_h), interpolation=cv2.INTER_AREA) canvas = np.zeros((target, target, 3), dtype=np.uint8) offset_x = (target - new_w) // 2 offset_y = (target - new_h) // 2 canvas[offset_y:offset_y + new_h, offset_x:offset_x + new_w] = resized return canvas def process_directory(src_dir, dst_dir): os.makedirs(dst_dir, exist_ok=True) for fname in os.listdir(src_dir): img_path = os.path.join(src_dir, fname) faces = detect_faces(img_path) if not faces: continue img = cv2.imread(img_path) for idx, face in enumerate(faces): face_img = crop_and_resize_face(img, face) if face_img is not None: out_name = f"{os.path.splitext(fname)[0]}_{idx}.jpg" cv2.imwrite(os.path.join(dst_dir, out_name), face_img)max(w, h) + 0.2 * max(w, h)是给脸周围留出耳朵和额头上方的空间,太贴边会让 DCGAN 学到的都是下巴纹理。cv2.INTER_AREA在缩小图片时能保留边缘信息,比默认的双线性插值更不容易产生锯齿。最后的中置填充不是多余操作,GAN 训练时如果图片里有大量黑色边缘,生成器会学着把脸画在图像正中央,这是好事——生成结果会更加规整。
3.3 清洗脏数据:模糊检测与重复去重
裁剪完的人脸不等于都是好数据。套图里常见三种脏数据:对焦不准导致的模糊脸、重复出现的同一张图(可能只是尺寸不同)、以及误检框出来的非人脸区域。模糊检测最简单的方法是拉普拉斯方差,方差越低代表图像越模糊;重复去重用感知哈希,两张图的相似度超过阈值就只保留一张。
def is_blurry(face_img, threshold=50): gray = cv2.cvtColor(face_img, cv2.COLOR_BGR2GRAY) return cv2.Laplacian(gray, cv2.CV_64F).var() < threshold def phash(img, hash_size=16): gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) resized = cv2.resize(gray, (hash_size, hash_size), interpolation=cv2.INTER_AREA) mean = resized.mean() return (resized > mean).astype(np.uint8).flatten() def hamming(a, b): return np.count_nonzero(a != b) def deduplicate(image_list): kept = [] hashes = [] for path in image_list: img = cv2.imread(path) if img is None: continue h = phash(img) if all(hamming(h, old) > 8 for old in hashes): hashes.append(h) kept.append(path) return keptthreshold=50不是绝对值,需要根据你的相机画质调整。如果套图本身来自专业摄影,人物皮肤纹理细腻,拉普拉斯方差通常能到 100 以上;如果是压缩严重的缩略图,40 都算清晰。我一般先随机抽 30 张裁剪结果看一遍,定一个能让 90% 正常图通过的阈值再跑全量。感知哈希的hash_size=16意味着把图缩到 16x16 再比较亮度分布,汉明距离小于 8 就视为重复,这个值对于同一张脸的不同裁切已经够用,误杀率不高。
注意:清洗这一步不能省。DCGAN 对噪声数据极其敏感,如果训练集里混进了模糊脸、侧面脸和误检背景,生成器会在训练中期开始「糊弄」,专门生成模糊图像来骗过判别器,这就是后面要讲的训练塌缩的常见诱因。
4. 训练 DCGAN 生成人脸:结构设计与参数配置
数据准备好之后,进入整个项目最核心的部分——DCGAN。DCGAN 是卷积版 GAN,它用转置卷积把随机噪声一步步放大成图像,用普通卷积做判别。相比原始的 GAN,DCGAN 出了一套很实用的网络结构规范:生成器里不用池化层,改用步长卷积;不带全连接层;除了输出层用 Tanh,其余层全部用 ReLU(生成器)或 LeakyReLU(判别器)。这套规范我今天还在用,因为它是经过大量实验验证的稳定结构,改起来有章可循。
4.1 生成器与判别器网络结构
生成器的输入是一个 100 维的随机噪声向量,经过四个转置卷积块,最终输出 3x64x64 的 RGB 图像。每个转置卷积层的通道数从 512 逐步降到 3,每次上采样尺寸翻倍。判别器正好相反,输入 3x64x64 图像,输出一个标量(真实概率)。
import torch import torch.nn as nn class Generator(nn.Module): def __init__(self, latent_dim=100, ngf=64): super().__init__() self.model = nn.Sequential( nn.ConvTranspose2d(latent_dim, ngf * 8, 4, 1, 0, bias=False), nn.BatchNorm2d(ngf * 8), nn.ReLU(True), nn.ConvTranspose2d(ngf * 8, ngf * 4, 4, 2, 1, bias=False), nn.BatchNorm2d(ngf * 4), nn.ReLU(True), nn.ConvTranspose2d(ngf * 4, ngf * 2, 4, 2, 1, bias=False), nn.BatchNorm2d(ngf * 2), nn.ReLU(True), nn.ConvTranspose2d(ngf * 2, ngf, 4, 2, 1, bias=False), nn.BatchNorm2d(ngf), nn.ReLU(True), nn.ConvTranspose2d(ngf, 3, 4, 2, 1, bias=False), nn.Tanh(), ) def forward(self, z): z = z.unsqueeze(2).unsqueeze(3) return self.model(z) class Discriminator(nn.Module): def __init__(self, ndf=64): super().__init__() self.model = nn.Sequential( nn.Conv2d(3, ndf, 4, 2, 1, bias=False), nn.LeakyReLU(0.2, inplace=True), nn.Conv2d(ndf, ndf * 2, 4, 2, 1, bias=False), nn.BatchNorm2d(ndf * 2), nn.LeakyReLU(0.2, inplace=True), nn.Conv2d(ndf * 2, ndf * 4, 4, 2, 1, bias=False), nn.BatchNorm2d(ndf * 4), nn.LeakyReLU(0.2, inplace=True), nn.Conv2d(ndf * 4, ndf * 8, 4, 2, 1, bias=False), nn.BatchNorm2d(ndf * 8), nn.LeakyReLU(0.2, inplace=True), nn.Conv2d(ndf * 8, 1, 4, 1, 0, bias=False), nn.Sigmoid(), ) def forward(self, img): return self.model(img).view(-1, 1)ConvTranspose2d(100, 512, 4, 1, 0)是把 100 维向量从 1x1 放大到 4x4 的第一层,这是 DCGAN 的经典起始配置。后面每层stride=2, padding=1都会让特征图尺寸翻倍:4→8→16→32→64。注意生成器的输出层用了Tanh,所以训练前要把人脸图像归一化到 [-1, 1] 区间,这个细节常被忽略——如果输入图像是 [0, 1],生成器的输出范围就对不上,损失会一直震荡不收敛。
4.2 训练循环与损失函数
DCGAN 的训练本质是极小极大博弈:判别器想让真实图片判定为 1、假图片判定为 0,生成器想让假图片被判定为 1。实现时用二元交叉熵即可,但两个网络的更新节奏和优化器参数要单独设置。生成器使用 Adam 学习率 0.0002,判别器也是 0.0002,但每轮要先冻住生成器更新判别器,再冻住判别器更新生成器。
import torch.optim as optim from torch.utils.data import DataLoader, Dataset import torchvision.transforms as transforms def train_gan(gen, disc, dataloader, epochs=200, device="cuda"): # 初始化权重,DCGAN 要求所有权重从均值为0标准差0.02的正态分布采样 def weights_init(m): classname = m.__class__.__name__ if classname.find("Conv") != -1: nn.init.normal_(m.weight.data, 0.0, 0.02) elif classname.find("BatchNorm") != -1: nn.init.normal_(m.weight.data, 1.0, 0.02) nn.init.constant_(m.bias.data, 0) gen.apply(weights_init) disc.apply(weights_init) opt_gen = optim.Adam(gen.parameters(), lr=0.0002, betas=(0.5, 0.999)) opt_disc = optim.Adam(disc.parameters(), lr=0.0002, betas=(0.5, 0.999)) criterion = nn.BCELoss() for epoch in range(epochs): for real_imgs, _ in dataloader: real_imgs = real_imgs.to(device) batch_size = real_imgs.size(0) label_real = torch.full((batch_size, 1), 1.0, device=device) label_fake = torch.full((batch_size, 1), 0.0, device=device) # 1. 训练判别器 disc.zero_grad() output_real = disc(real_imgs) loss_real = criterion(output_real, label_real) z = torch.randn(batch_size, 100, device=device) fake_imgs = gen(z) output_fake = disc(fake_imgs.detach()) loss_fake = criterion(output_fake, label_fake) loss_disc = loss_real + loss_fake loss_disc.backward() opt_disc.step() # 2. 训练生成器 gen.zero_grad() output_fake2 = disc(fake_imgs) loss_gen = criterion(output_fake2, label_real) # 希望假图被判为真 loss_gen.backward() opt_gen.step() if (epoch + 1) % 10 == 0: print(f"Epoch [{epoch+1}/{epochs}] D损失: {loss_disc.item():.4f} G损失: {loss_gen.item():.4f}")disc(fake_imgs.detach())中的detach()很关键,它切断了判别器反传时到生成器的梯度,确保这一步只更新判别器。betas=(0.5, 0.999)是 DCGAN 官方实现里的设定,默认的 Adam 用0.9在 GAN 训练里容易震荡,改成0.5能抑制动量过大带来的不收敛。损失打印不需要每个 batch 都做,每 10 个 epoch 看一眼即可,重点是趋势:如果判别器损失长期趋近 0,说明生成器已经完全掉队了。
4.3 图像输出与模型保存
训练过程中如果不定期看图,你根本不知道模型是在学人脸还是已经在生成噪点。一般每 5 个 epoch 生成一批固定噪声的假图,保存成一张网格图。固定噪声很重要——只有输入相同的 z,你才能清晰对比每个 epoch 的生成效果变化。
import torchvision.utils as vutils fixed_noise = torch.randn(64, 100, device="cuda") def save_samples(gen, noise, epoch, save_path): gen.eval() with torch.no_grad(): fake = gen(noise).detach().cpu() grid = vutils.make_grid(fake, nrow=8, normalize=True, value_range=(-1, 1)) vutils.save_image(grid, f"{save_path}/epoch_{epoch:03d}.png") gen.train() def save_checkpoint(gen, disc, opt_gen, opt_disc, epoch, path): torch.save({ "epoch": epoch, "gen_state": gen.state_dict(), "disc_state": disc.state_dict(), "opt_gen": opt_gen.state_dict(), "opt_disc": opt_disc.state_dict(), }, path)make_grid的normalize=True, value_range=(-1, 1)会把生成器的 Tanh 输出映射到 [0, 1],不然保存出来的图片会黑乎乎一片。eval()和train()的切换不能省,因为生成器里的 BatchNorm 在推理和训练时行为不同,不切换会导致 validation 结果不稳定。checkpoint 建议连优化器状态一起保存,这样中断训练后能精确恢复到之前的 Adam 动量,而不是从零再来。
5. DCGAN 训练避坑指南:从塌缩到 NaN 的常见问题
这部分是血泪经验。我用 DCGAN 跑过人脸、车、动漫头像,踩过的坑基本集中在四类:训练塌缩、损失 NaN、生成图全是噪点、显存不足。每个坑我都按「现象 → 原因 → 解决」写清楚,你照着排查能省至少两天的调参时间。
5.1 训练塌缩:判别器太强导致生成器梯度消失
现象:训练到 200 轮左右,判别器损失降到 0.01,生成器损失涨到 5 以上,生成的图片变成一张固定的模糊脸,无论输入什么噪声都输出同一张图。原因:判别器学会了只靠个别特征(比如肤色分布)就能区分真假,生成器找不到可学的梯度,开始输出一个能骗过判别器的均值图像。解决:最有效的是降低判别器能力,例如把判别器的通道数减半,或者每训练一次判别器就训练两次生成器。
# 在训练循环里改为:生成器更新2次,判别器更新1次 for _ in range(2): gen.zero_grad() z = torch.randn(batch_size, 100, device=device) fake_imgs = gen(z) output_fake = disc(fake_imgs) loss_gen = criterion(output_fake, label_real) loss_gen.backward() opt_gen.step()这个方法的核心思想是给生成器更多「说话机会」,让它在判别器还没完全站稳时先跟上去。另一个技巧是给判别器的真实标签加少量噪声,比如用 0.9 而不是 1.0 当真实标签,这会迫使判别器决策边界不那么尖锐,间接帮助生成器。
5.2 损失变成 NaN:学习率与初始化
现象:某个 epoch 开始,D 损失和 G 损失突然变成nan,然后一直无法恢复。原因:训练过程发散,常见于学习率过大或者 BatchNorm 在 batch size 太小时统计量不稳定。解决:先把学习率从0.0002降到0.0001,同时检查 batch size,至少别小于 64。如果仍然 NaN,就把所有卷积层的权重重新初始化一次。
def reset_model(gen, disc): gen.apply(weights_init) disc.apply(weights_init)注意weights_init必须重新给每个子模块执行一遍,不能只调用model.reset_parameters(),因为 Conv 和 BatchNorm 的初始化函数不同。还有一种隐蔽原因是数据里有 NaN 值——图片下载后有些损坏的 JPEG 能被cv2.imread读出但像素是异常值,建议裁剪时加上np.isfinite(img).all()检查。
5.3 生成图全是噪点:人脸没有对齐
现象:生成器不塌缩,损失也正常下降,但生成的图片里看不到完整人脸轮廓,只有一团团有皮肤纹理的色块。原因:训练图像里的人脸位置没有对齐,有的脸在图像中央,有的在左上角,有的脸占整张图,有的只占三分之一。DCGAN 的卷积核没有平移不变性(不是 CNN 的共享权重能解决的问题),它学到的是「脸必须在固定位置出现」这一强先验。解决:回炉第三节,把裁剪的边距比例调小,强制人脸占图像面积 70% 以上。
注意:人脸对齐是 DCGAN 项目里最重要的一步,比调网络结构还重要。我试过用 3000 张没有裁剪对齐的图训练,最后生成的永远是一堆色块;把同样数据重新裁剪到 64x64 并让人脸居中后,同一套代码在 100 个 epoch 就能看出清晰的五官轮廓。
5.4 显存不足:批量大小与图像分辨率权衡
现象:程序启动后直接报CUDA out of memory,甚至把显存占用到 12GB 还不够。原因:生成器和判别器各占一份显存,64 张 64x64 的图对计算图的要求已经很高,如果切成 128x128 尺寸,单 batch 32 张都会爆。解决:先固定 batch size = 64,如果爆了再降 batch;或者用torch.cuda.amp混合精度训练,把显存占用降一半。
from torch.cuda.amp import autocast, GradScaler scaler = GradScaler() with autocast(): output_fake = disc(fake_imgs.detach()) loss_fake = criterion(output_fake, label_fake) scaler.scale(loss_fake).backward() scaler.step(opt_disc) scaler.update()注意混合精度不是万能的,它会让 BN 层的计算有轻微精度损失,但人脸生成任务对精度不敏感。另一个实用技巧是关掉训练时的torch.backends.cudnn.benchmark,默认开启的 autocast 在某些显卡上会自动优化算法选择,反而导致显存碎片化加剧。如果不确定,手动设torch.backends.cudnn.benchmark = False更省心。
6. 让生成结果更接近真人:预处理增强与后期筛选
最后这一章不聊网络结构了,聊两个让你超出「能跑通」水平、真正把画质做上去的手段:数据增强和生成结果筛选。训练到后期你会发现,单纯加大 epoch 数并不能提升细节锐度,DCGAN 的瓶颈往往在数据多样性上。
6.1 调整裁剪策略:偏移与旋转增强
通用的人脸数据集在送入 DCGAN 前通常没有做随机裁剪,但写真套图里的人物姿势、表情、拍摄角度高度相似,连续多张图几乎是一个模子刻出来的。我建议把裁剪逻辑加一点「随机扰动」:每次从人脸框的位置随机偏移 5 个像素、旋转 ±5 度,再缩放到目标尺寸。
def crop_with_augment(img, face, target=64, jitter=5, angle=5): x, y, w, h = face cx, cy = x + w // 2, y + h // 2 side = int(max(w, h) * 1.2) # 随机偏移 cx += np.random.randint(-jitter, jitter + 1) cy += np.random.randint(-jitter, jitter + 1) half = side // 2 x0, y0 = max(cx - half, 0), max(cy - half, 0) x1, y1 = min(cx + half, img.shape[1]), min(cy + half, img.shape[0]) crop = img[y0:y1, x0:x1] # 随机旋转并缩放 M = cv2.getRotationMatrix2D((crop.shape[1] // 2, crop.shape[0] // 2), angle, 1.0) crop = cv2.warpAffine(crop, M, (crop.shape[1], crop.shape[0])) # 继续走等比例缩放和居中填充 ...偏移量jitter=5对应 64x64 的图约 8% 的平移量,不会导致脸跑到画布外。旋转角度超过 10 度就会引入黑边,所以控制在 5 度以内。这套增强不是官方标准,而是我从实际效果里试出来的——它能显著增加 DCGAN 看到的样本分布范围,尤其在训练集只有两三千张的时候,生成器更容易学到姿态变化而不是死记硬背。
6.2 用 Fréchet Inception Distance 评估生成质量
平时我们看生成图全凭主观,但两个人脸相似度很高不代表 DCGAN 训练得好。为了量化效果,我习惯用 FID(Fréchet Inception Distance)指标。FID 衡量真实图集和生成图集在 Inception 特征空间的分布距离,数值越低越好。这个指标也能当早期停止依据:FID 降到相对低位后继续训练,通常只会过拟合或者塌缩。
# 伪代码:实际使用需要安装 torchvision 的 inception 特征提取 from torchvision.models import inception_v3 import numpy as np def compute_fid(real_features, fake_features): mu_real, sigma_real = real_features.mean(0), np.cov(real_features, rowvar=False) mu_fake, sigma_fake = fake_features.mean(0), np.cov(fake_features, rowvar=False) diff = mu_real - mu_fake covmean = sqrtm(sigma_real.dot(sigma_fake)) fid = diff.dot(diff) + np.trace(sigma_real + sigma_fake - 2 * covmean) return fidsqrtm来自scipy.linalg,计算两个协方差矩阵的平方根,这一步如果矩阵不可逆会报错,可以用np.linalg.eig手动求。FID 不需要每个 epoch 都算,一般每 20 个 epoch 算一次,计算时随机抽 1000 张真实图和 1000 张生成图就够稳定了。注意 FID 对图像分辨率敏感,统一都用 64x64 的输入算特征,不要拿原始大图对比。
6.3 我的筛选习惯与最终建议
训练跑完后,直接把fixed_noise生成的所有图挑出来是远远不够的。我现在会专门写一段筛选脚本:先对生成图片做模糊检测,把低于阈值的扔掉,再用 OpenCV 的CascadeClassifier检测生成图里有没有人脸,如果没有就说明这一轮生成的完全不可用。
def filter_generated_images(gen, noise, threshold=0.3): gen.eval() with torch.no_grad(): fake = gen(noise).cpu() good_indices = [] for i, img in enumerate(fake): img_np = (img.permute(1, 2, 0).numpy() * 0.5 + 0.5) * 255 img_np = img_np.astype(np.uint8) faces = face_cascade.detectMultiScale(img_np, 1.1, 5, minSize=(24, 24)) blur_score = cv2.Laplacian(cv2.cvtColor(img_np, cv2.COLOR_RGB2GRAY), cv2.CV_64F).var() if len(faces) >= 1 and blur_score > 20: good_indices.append(i) gen.train() return good_indices注意检测用的人脸 cascade 参数minSize=(24, 24)和筛选取材形的(48, 48)不一样,因为生成图的清晰度本来就不高,检测框太大容易漏检。训练完的生成器保存好 checkpoint 后,我习惯把每次生成的筛选结果按 FID 排序,只保留排名前 20% 的图像作为后续人工挑选的候选集。这个过程很笨但有效——DCGAN 是生成模型,它输出的所有图不可能张张都好,你要做的是选出它的上限而不是平均质量。
说实话,跑通这套流程后最大的感受是:爬虫代码再烂也能靠中断重跑救回来,但 DCGAN 训练一旦塌缩,回滚到之前的 checkpoint 往往也救不回来,只能重新调节学习率和数据清洗策略。从那以后我每次开始训练 GAN 之前,都会强制自己先花一小时检查三件事:训练集里有没有模糊脸、人脸是否对齐、生成器和判别器初始权重是否合规。这三关过了,模型大概率能跑到 epoch 200 不翻车。希望帮到你。
本文还有配套的精品资源,点击获取