脸部LoRA训练项目:素材流水线 + Gradio控制台 + RunPod云端续炼方案(v7续炼完成)

This commit is contained in:
hmo
2026-08-10 10:10:34 +08:00
commit 77d0cbd0cb
29 changed files with 5898 additions and 0 deletions
+2224
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+305
View File
@@ -0,0 +1,305 @@
# -*- coding: utf-8 -*-
"""
merge_fixed.py — 原图 + AI修改图 无缝融合工具
============================================
背景:ComfyUI inpainting 移除合影中的其他人物时,即使指定"非修改区域不得像素漂移"
全图清晰度仍不可避免损失。本工具把:
- 原图的"未修改区域"(清晰度完好)
- 修改图的"修改区域"(人物被干净移除)
自动合并,过渡自然无痕迹。
用法:
python merge_fixed.py <原图目录> <修改图目录> <输出目录> [--feather 25] [--threshold 20]
匹配规则:
- 原图 IMG_0463.jpg(任意后缀 jpg/png/webp/jpeg
- 修改图 IMG_0463*.png(以原图文件名开头、.png 结尾、中间任意字符)
- 一张原图可有多张修改图,每张都独立合并
- 输出:{原图stem}_{修改图中间部分}.png
算法:
1. 修改图 resize 到原图尺寸
2. Lab 色彩空间差异图 → 高斯模糊去噪 → 阈值 → 闭运算+膨胀 = 修改区 mask
3. 羽化混合:result = 原图*(1-alpha) + 修改图*alphaalpha 由 mask 高斯模糊得到)
"""
import argparse
import os
import sys
from pathlib import Path
import cv2
import numpy as np
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
# 复用 face_checker 的人脸检测(目标人物面部保护)
sys.path.insert(0, str(Path(__file__).resolve().parent))
import face_checker as fc
IMG_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".bmp"}
_fc_det = None
def get_face_detector():
global _fc_det
if _fc_det is None:
_fc_det = fc.FaceDetector()
return _fc_det
def protect_faces(alpha, mod_rgb, expand=1.35, margin=40):
"""
面部强制保护(保险):检测修改图中残留的人脸(=目标人物,被移除者已被 inpaint 掉),
把这些人脸区域的 alpha 置 0(100% 用原图像素,零改变)。
通用规则(有限修改→原图)已覆盖大部分,此项兜底确保面部绝对不变。
"""
det = get_face_detector()
faces = fc._detect_faces_fast(mod_rgb, det)
if not faces:
return alpha
for f in faces:
x, y, w, h = f[0], f[1], f[2], f[3]
cw, ch = w * expand, h * expand
x0 = max(0, int(x - (cw - w) / 2) - margin)
y0 = max(0, int(y - (ch - h) / 2) - margin)
x1 = min(alpha.shape[1], int(x + w + (cw - w) / 2) + margin)
y1 = min(alpha.shape[0], int(y + h + (ch - h) / 2) + margin)
alpha[y0:y1, x0:x1] = 0.0
return alpha
def build_alpha_from_mask(mask_rgb, feather=20):
"""
从 ComfyUI inpaint 蒙版生成 alpha:白色区域 = 被修改区(100% 修改图),黑色 = 原图。
蒙版是最准确的"修改区"标记(用户在 ComfyUI 画的就是要移除的人物),
比 diff 检测可靠 100 倍——紧贴人物的残影完美解决。
feather: 蒙版边缘羽化像素
"""
gray = cv2.cvtColor(mask_rgb, cv2.COLOR_RGB2GRAY).astype(np.float32) / 255.0
# 蒙版白色→1(修改图),黑色→0(原图)
alpha = gray
if feather > 0:
k = feather * 2 + 1
alpha = cv2.GaussianBlur(alpha, (k, k), 0)
return alpha
def find_mask(orig_path, mask_dir):
"""按文件名匹配蒙版:{stem}*mask*.png 或 {stem}*.png(取 mask 关键字优先)"""
stem = Path(orig_path).stem
cands = sorted(p for p in mask_dir.iterdir()
if p.suffix.lower() == ".png" and p.name.startswith(stem))
# 优先带 mask 关键字的
for p in cands:
if "mask" in p.name.lower():
return p
return cands[0] if cands else None
def build_alpha(orig_rgb, mod_rgb, blur_kernel=15, threshold=4.0, min_area_ratio=0.005,
softness=1.5, dilate=60, body_extend=2.2):
"""
融合权重 alpha(0=原图像素, 1=修改图像素)——双图人脸差集定位被移除人物:
1. 原图人脸 - 修改图人脸 = 被移除人物(合影中被清掉的人)
2. 被移除人物区域(脸框 + 向下 body_extend 倍覆盖身体)= 修改区
3. 目标人物(修改图残留的最大人脸)紧贴框 = 保留区(强制原图)
4. diff 高值连通块(排除保留区)兜底其他修改
5. 膨胀覆盖边缘残影
紧贴合影场景:被移除人物与目标紧贴时,靠"人脸差集"精确定位,不靠 diff 幅度。
"""
det = get_face_detector()
orig_faces = fc._detect_faces_fast(orig_rgb, det)
mod_faces = fc._detect_faces_fast(mod_rgb, det)
h, w = orig_rgb.shape[:2]
def matched(of, mfaces):
ox, oy = of[0] + of[2] / 2, of[1] + of[3] / 2
return any(abs(ox - (mf[0] + mf[2] / 2)) < of[2] * 0.8 and abs(oy - (mf[1] + mf[3] / 2)) < of[3] * 0.8
for mf in mfaces)
removed_faces = [f for f in orig_faces if not matched(f, mod_faces)]
# 过滤误检小脸(背景物体/花纹被当脸):只保留足够大的人脸
# 真实人物脸 ≥ 图面积 0.05% 或短边 ≥ 40px
min_face_area = max(1, int(h * w * 0.0005))
removed_faces = [f for f in removed_faces
if f[2] * f[3] >= min_face_area and min(f[2], f[3]) >= 40]
# 被移除人物区(脸 + 身体向下扩展)
mod_mask = np.zeros((h, w), np.uint8)
for f in removed_faces:
x, y, fw, fh = [int(v) for v in f[:4]]
x0 = max(0, int(x - fw * 0.4)); x1 = min(w, int(x + fw * 1.4))
y0 = max(0, int(y - fh * 0.3)); y1 = min(h, int(y + fh * body_extend))
mod_mask[y0:y1, x0:x1] = 1
# 保留区:目标人物最大脸,紧贴框(左右不扩,防侵入紧贴的被移除人物)
keep_mask = np.zeros((h, w), np.uint8)
if mod_faces:
big = max(mod_faces, key=lambda f: f[2] * f[3])
x, y, fw, fh = [int(v) for v in big[:4]]
x0, x1 = max(0, x), min(w, x + fw)
y0, y1 = max(0, int(y - fh * 0.35)), min(h, int(y + fh * 1.2))
keep_mask[y0:y1, x0:x1] = 1
# diff 兜底连通块(排除保留区)。无被移除人物时用高阈值(保守,防降质误判)
o_lab = cv2.cvtColor(orig_rgb, cv2.COLOR_RGB2LAB).astype(np.float32)
m_lab = cv2.cvtColor(mod_rgb, cv2.COLOR_RGB2LAB).astype(np.float32)
diff_s = cv2.GaussianBlur(np.abs(o_lab - m_lab).mean(axis=2), (blur_kernel, blur_kernel), 0)
eff_threshold = threshold if removed_faces else max(threshold, 15.0)
base = ((diff_s > eff_threshold) & ~keep_mask.astype(bool)).astype(np.uint8)
num, labels, stats, _ = cv2.connectedComponentsWithStats(base, 8)
min_area = max(1, int(min_area_ratio * h * w))
filtered = np.zeros_like(base)
for i in range(1, num):
if stats[i, cv2.CC_STAT_AREA] >= min_area:
filtered[labels == i] = 1
core = np.maximum(filtered, mod_mask)
if dilate > 0:
core = cv2.dilate(core, np.ones((dilate, dilate), np.uint8))
alpha = core.astype(np.float32)
if softness > 0:
k = int(softness * 6) * 2 + 1
alpha = cv2.GaussianBlur(alpha, (k, k), 0)
return alpha
"""
融合权重 alpha(0=原图像素, 1=修改图像素):
- 固定阈值判定明显修改区(人物移除 diff 高,有限降质 diff 低,区分度 ~20x)
- diff > 阈值 → alpha≈1100% 修改图,修改区内部零残影)
- diff < 阈值 → alpha≈0100% 原图,目标人物/有限修改区零改变)
- 修改区膨胀 dilate 像素(默认 75):覆盖人物边缘 10-40px 的浅残影带
(残影带 diff 接近未修改区,无法用阈值检测,必须靠膨胀)
- 陡 sigmoid 边缘过渡防硬边
"""
o = cv2.cvtColor(orig_rgb, cv2.COLOR_RGB2LAB).astype(np.float32)
m = cv2.cvtColor(mod_rgb, cv2.COLOR_RGB2LAB).astype(np.float32)
diff = np.abs(o - m).mean(axis=2)
diff_s = cv2.GaussianBlur(diff, (blur_kernel, blur_kernel), 0)
alpha = 1.0 / (1.0 + np.exp(-(diff_s - threshold) / softness))
# 修改区膨胀:覆盖人物边缘残影带
if dilate > 0:
kernel = np.ones((dilate, dilate), np.uint8)
core = (alpha > 0.5).astype(np.uint8)
core = cv2.dilate(core, kernel)
alpha = np.maximum(alpha, core.astype(np.float32))
return alpha
def seamless_merge(orig_rgb, mod_rgb, alpha):
"""
连续 alpha 混合:result = orig*(1-alpha) + mod*alpha。
alpha 已是平滑的浮点图(sigmoid 过渡),无需额外羽化。
"""
a = alpha[..., None].astype(np.float32)
result = orig_rgb.astype(np.float32) * (1.0 - a) + mod_rgb.astype(np.float32) * a
return np.clip(result, 0, 255).astype(np.uint8)
def find_modified(orig_path, mod_dir):
"""按文件名匹配修改图:{stem}*.png"""
stem = Path(orig_path).stem
return sorted(p for p in mod_dir.iterdir()
if p.suffix.lower() == ".png" and p.name.startswith(stem))
def process(orig_dir, mod_dir, out_dir, alpha_threshold=8.0, softness=1.5, protect=True,
mask_dir=None, feather=20, verbose=True):
"""
alpha_threshold: 无蒙版时的 diff 阈值(默认 8;误判时调)
softness: 过渡带宽度
protect: 面部强制保护(无蒙版 fallback 时的保险)
mask_dir: ComfyUI inpaint 蒙版目录(可选,有则优先用,蒙版白色=被移除区,最准确)
feather: 蒙版边缘羽化
"""
orig_dir, mod_dir, out_dir = Path(orig_dir), Path(mod_dir), Path(out_dir)
if mask_dir:
mask_dir = Path(mask_dir)
out_dir.mkdir(parents=True, exist_ok=True)
originals = sorted(p for p in orig_dir.iterdir()
if p.suffix.lower() in IMG_EXTS and not p.name.startswith("."))
if not originals:
print(f"[WARN] 原图目录没有图片: {orig_dir}")
return
total = 0
for op in originals:
mods = find_modified(op, mod_dir)
if not mods:
if verbose:
print(f"[跳过] {op.name}: 无匹配修改图")
continue
from PIL import Image
orig_img = Image.open(op).convert("RGB")
ow, oh = orig_img.size
orig_rgb = np.array(orig_img)
# 尝试匹配蒙版
mask_path = find_mask(op, mask_dir) if mask_dir else None
for mp in mods:
try:
mod_img = Image.open(mp).convert("RGB")
if mod_img.size != (ow, oh):
mod_img = mod_img.resize((ow, oh), Image.LANCZOS)
mod_rgb = np.array(mod_img)
if mask_path and mask_path.exists():
# 蒙版模式:最准确
mask_img = Image.open(mask_path).convert("RGB")
if mask_img.size != (ow, oh):
mask_img = mask_img.resize((ow, oh), Image.LANCZOS)
mask_rgb = np.array(mask_img)
alpha = build_alpha_from_mask(mask_rgb, feather=feather)
mode = f"蒙版模式({mask_path.name})"
else:
# 双图人脸差集模式(自动定位被移除人物 + 保留目标脸)
alpha = build_alpha(orig_rgb, mod_rgb, threshold=alpha_threshold, softness=softness)
mode = "差集模式"
area_ratio = float((alpha > 0.5).mean())
if area_ratio < 0.0005:
if verbose:
print(f"[警告] {op.name} <- {mp.name}: 明显修改区仅 {area_ratio*100:.2f}%%,可能未检测到修改")
mask_area_note = f"明显修改区 {area_ratio*100:.2f}% (偏小?)"
else:
mask_area_note = f"明显修改区 {area_ratio*100:.1f}%"
result = seamless_merge(orig_rgb, mod_rgb, alpha)
mid = mp.stem[len(Path(op).stem):] or "_mod"
out_name = f"{Path(op).stem}{mid}.png"
Image.fromarray(result).save(out_dir / out_name)
total += 1
if verbose:
print(f"[OK] {out_name} | {mode} | {mask_area_note}")
except Exception as e:
if verbose:
print(f"[失败] {op.name} <- {mp.name}: {e}")
print(f"\n完成:共输出 {total} 张融合图 -> {out_dir}")
def main():
ap = argparse.ArgumentParser(description="原图 + AI修改图 无缝融合工具(支持 ComfyUI 蒙版)")
ap.add_argument("orig_dir", help="原图目录")
ap.add_argument("mod_dir", help="修改图目录(文件名以原图名开头、.png 结尾)")
ap.add_argument("out_dir", help="输出目录")
ap.add_argument("--mask-dir", default=None,
help="ComfyUI inpaint 蒙版目录(可选,有则优先用;蒙版白色=被移除区,最准确解决紧贴残影)")
ap.add_argument("--alpha-threshold", type=float, default=8.0,
help="无蒙版时的 diff 阈值(默认 8")
ap.add_argument("--softness", type=float, default=1.5, help="过渡带宽度(默认 1.5")
ap.add_argument("--feather", type=int, default=20, help="蒙版边缘羽化(默认 20")
ap.add_argument("--no-protect-face", action="store_true", help="关闭面部强制保护(无蒙版时的保险)")
args = ap.parse_args()
process(args.orig_dir, args.mod_dir, args.out_dir,
alpha_threshold=args.alpha_threshold, softness=args.softness,
protect=not args.no_protect_face, mask_dir=args.mask_dir, feather=args.feather)
if __name__ == "__main__":
main()
Binary file not shown.
+236
View File
@@ -0,0 +1,236 @@
# FaceLoRA v3 training monitor (scheduled task, fully autonomous)
# ============================================================
# 用途:云端训练全自动监控。Windows 计划任务每 5 分钟调用一次。
# 功能:
# 1. 增量下载 checkpointHTTP HEAD 探测 + aria2c)——任何时刻断线已下载的都是安全的
# 2. 完成判定:e120 + final 都下载 -> 删 pod 停计费
# 3. 崩溃检测:最后 checkpoint 45min 无更新 -> 抢救 + 删 pod
# 4. 超时止损:超过预算小时数 -> 删 pod
# 5. 状态持久化到 monitor_state.txt(纯文本,PS5.1 下 .json 写入不可靠)
# 输出(全在 ASCII 目录 C:\Users\hmo\AppData\Local\Temp\opencode\face_lora\):
# monitor_v3.log - 详细运行日志
# train_events.log - 关键事件时间线(下载/完成/止损/删pod)
# monitor_state.txt - 持久化状态(pod_start/downloaded/done/started
# checkpoints_v3/ - 已下载的 checkpoint
#
# !! PS 5.1 铁律(血泪教训,2026-08-09):
# - 变量名不区分大小写!$STATE(路径) 会被 $state(hashtable) 覆盖 → 路径变量必须用独特名($STATEFILE)
# - 中文路径下 Add-Content 写新 .json 文件不可靠 → 输出目录用纯 ASCII,状态用纯文本
# - train_env.json 读取失败必须退出,绝不用空值执行删 pod(曾因此差点误删)
# ============================================================
$ErrorActionPreference = "Continue"
# ---- 项目路径(注意:脚本必须存 GBK 编码,PowerShell 5.1 用 ANSI 读)----
$PROJ = "D:\F\NewI\opencode\daily-workspace\projects\脸部LoRA训练-Qwen-Image"
# 输出目录用纯 ASCII(避开中文路径 + PS5.1 编码 bug
$OUTDIR = "C:\Users\hmo\AppData\Local\Temp\opencode\face_lora"
# ---- 读取训练环境配置(train_env.jsonpod_id/dl_url/ssh/max_hours/total_steps----
$CFG = Join-Path $PROJ "temp\train_env.json"
if (-not (Test-Path $CFG)) {
Write-Host "missing train_env.json"
exit 1
}
$env_cfg = Get-Content $CFG -Raw | ConvertFrom-Json
$PODID = $env_cfg.pod_id
$DLURL = $env_cfg.dl_url
$SSHHOST = $env_cfg.ssh_host
$SSHPORT = $env_cfg.ssh_port
$MAX_HOURS = [double]$env_cfg.max_hours
$TOTAL_STEPS = [int]$env_cfg.total_steps
# 安全阀:任何关键字段为空 → 立即退出,绝不用空值执行删 pod 等破坏性操作
if (-not $PODID -or -not $DLURL -or $MAX_HOURS -le 0) {
Write-Host ("train_env invalid: pod=" + $PODID + " max_hours=" + $MAX_HOURS)
exit 1
}
$MLOG = Join-Path $OUTDIR "monitor_v3.log"
$EVENT = Join-Path $OUTDIR "train_events.log"
$CDIR = Join-Path $OUTDIR "checkpoints_v3"
$STATEFILE = Join-Path $OUTDIR "monitor_state.txt"
$RPKEY = (Get-Content (Join-Path $PROJ ".runpod_api_key") -Raw).Trim()
New-Item -ItemType Directory -Force $CDIR, (Split-Path $MLOG) | Out-Null
# ---- 单例守卫(防计划任务重复触发)----
$LOCK = Join-Path $OUTDIR "monitor_v3.lock"
if (Test-Path $LOCK) {
$oldPid = [int](Get-Content $LOCK)
if (Get-Process -Id $oldPid -ErrorAction SilentlyContinue) {
exit 0
}
}
Set-Content $LOCK $PID
function Log($msg) {
$ts = Get-Date -Format "MM-dd HH:mm:ss"
Add-Content -Path $MLOG -Value ("[" + $ts + "] " + $msg) -Encoding UTF8
}
function Event($msg) {
$ts = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
Add-Content -Path $EVENT -Value ("[" + $ts + "] " + $msg) -Encoding UTF8
}
try {
# ---- 读持久化状态(纯文本)----
$state = @{ downloaded = @(); pod_start = ""; done = $false; started = $false }
if (Test-Path $STATEFILE) {
try {
foreach ($ln in Get-Content $STATEFILE) {
if ($ln -like "downloaded=*") { $state.downloaded = @($ln.Substring(11).Split(";") | Where-Object { $_ }) }
elseif ($ln -like "pod_start=*") { $state.pod_start = $ln.Substring(10) }
elseif ($ln -eq "done=true") { $state.done = $true }
elseif ($ln -eq "started=true") { $state.started = $true }
}
} catch {}
}
if (-not $state.pod_start) {
$state.pod_start = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
Event ("monitor start: pod=" + $PODID + " dl_url=" + $DLURL + " max_hours=" + $MAX_HOURS)
}
if ($state.done) {
Log "done, skip"
exit 0
}
# ---- 0. 探测训练是否已开始(train.log 有 steps----
if (-not $state.started) {
try {
$tail = & ssh -i "$env:USERPROFILE\.ssh\id_rsa" -p $SSHPORT -o BatchMode=yes -o ConnectTimeout=10 $SSHHOST "tail -c 2000 /workspace/train.log 2>/dev/null | tr '\r' '\n' | tail -2" 2>$null
if ($tail -match ("(\d+)/" + $TOTAL_STEPS)) {
$state.started = $true
Event ("training started: step " + $Matches[1] + "/" + $TOTAL_STEPS)
}
} catch {}
}
# ---- 1. 探测云端 checkpointHTTP HEAD----
$epochs = @(10,20,30,40,50,60,70,80,90,100,110,120)
$newFiles = @()
foreach ($e in $epochs) {
$fn = "myface_lora-{0:D6}.safetensors" -f $e
if ($fn -in $state.downloaded) { continue }
try {
$resp = Invoke-WebRequest -Uri ($DLURL + "/" + $fn) -Method Head -TimeoutSec 15 -UseBasicParsing
if ($resp.StatusCode -eq 200) { $newFiles += $fn }
} catch {}
}
try {
$resp = Invoke-WebRequest -Uri ($DLURL + "/myface_lora.safetensors") -Method Head -TimeoutSec 15 -UseBasicParsing
if ($resp.StatusCode -eq 200) { $newFiles += "myface_lora.safetensors" }
} catch {}
# ---- 2. 增量下载 ----
foreach ($f in $newFiles) {
$fp = Join-Path $CDIR $f
if (Test-Path $fp) {
$sz = [math]::Round((Get-Item $fp).Length/1MB,0)
if ($sz -gt 100) {
Log ("already have: " + $f + " (" + $sz + " MB), skip")
$state.downloaded += $f
continue
}
Remove-Item $fp -Force -ErrorAction SilentlyContinue
}
Log ("new checkpoint: " + $f + " downloading")
& aria2c -x16 -s16 -k1M --continue -d "$CDIR" -o $f ($DLURL + "/" + $f) 2>&1 | Out-Null
if (Test-Path $fp) {
$sz = [math]::Round((Get-Item $fp).Length/1MB,0)
if ($sz -gt 100) {
Log ("download OK: " + $f + " (" + $sz + " MB)")
Event ("CHECKPOINT DOWNLOADED: " + $f + " (" + $sz + " MB)")
$state.downloaded += $f
} else {
Remove-Item $fp -Force -ErrorAction SilentlyContinue
Log ("incomplete (" + $sz + " MB), retry next round: " + $f)
}
} else {
Log ("download FAIL: " + $f)
}
break
}
# ---- 3. 完成判定 ----
$hasFinal = "myface_lora.safetensors" -in $state.downloaded
if ($hasFinal) {
Log "=== TRAINING COMPLETE: final checkpoint downloaded ==="
Event ("=== TRAINING COMPLETE: all checkpoints in checkpoints_v3\ ===")
try {
Invoke-RestMethod -Uri ("https://rest.runpod.io/v1/pods/" + $PODID) -Method Delete -Headers @{Authorization = "Bearer $RPKEY"} -TimeoutSec 30 | Out-Null
Log ("pod " + $PODID + " deleted (complete)")
Event ("POD DELETED: " + $PODID + " (complete, billing stopped)")
} catch { Log ("pod delete fail: " + $_.Exception.Message) }
$state.done = $true
}
# ---- 4. 崩溃检测 ----
if ($state.downloaded.Count -gt 0 -and -not $state.done) {
$lastFile = $state.downloaded[-1]
$lastPath = Join-Path $CDIR $lastFile
if (Test-Path $lastPath) {
$ageMin = [int]((Get-Date) - (Get-Item $lastPath).LastWriteTime).TotalMinutes
if ($ageMin -gt 45) {
Log ("CRASH: last ckpt (" + $lastFile + ") " + $ageMin + "min stale -> rescue + delete pod")
Event ("CRASH: last ckpt " + $lastFile + " " + $ageMin + "min stale -> rescue + delete pod")
foreach ($e in $epochs) {
$fn = "myface_lora-{0:D6}.safetensors" -f $e
if ($fn -in $state.downloaded) { continue }
try {
$r = Invoke-WebRequest -Uri ($DLURL + "/" + $fn) -Method Head -TimeoutSec 15 -UseBasicParsing
if ($r.StatusCode -eq 200) {
& aria2c -x16 -s16 -k1M -d "$CDIR" -o $fn ($DLURL + "/" + $fn) 2>&1 | Out-Null
if (Test-Path (Join-Path $CDIR $fn)) { $state.downloaded += $fn; Event ("RESCUED: " + $fn) }
}
} catch {}
}
try {
Invoke-RestMethod -Uri ("https://rest.runpod.io/v1/pods/" + $PODID) -Method Delete -Headers @{Authorization = "Bearer $RPKEY"} -TimeoutSec 30 | Out-Null
Log ("pod " + $PODID + " deleted (crash)")
Event ("POD DELETED: " + $PODID + " (crash stop)")
} catch { Log ("pod delete fail: " + $_.Exception.Message) }
$state.done = $true
}
}
}
# ---- 5. 超时止损 ----
if (-not $state.done) {
try {
$start = [datetime]::ParseExact($state.pod_start, "yyyy-MM-dd HH:mm:ss", $null)
$runHours = ((Get-Date) - $start).TotalHours
if ($runHours -gt $MAX_HOURS) {
Log ("TIMEOUT: " + [math]::Round($runHours,1) + "h > budget " + $MAX_HOURS + "h")
Event ("TIMEOUT: " + [math]::Round($runHours,1) + "h > budget " + $MAX_HOURS + "h -> delete pod")
try {
Invoke-RestMethod -Uri ("https://rest.runpod.io/v1/pods/" + $PODID) -Method Delete -Headers @{Authorization = "Bearer $RPKEY"} -TimeoutSec 30 | Out-Null
Log ("pod " + $PODID + " deleted (timeout, downloaded " + $state.downloaded.Count + ")")
Event ("POD DELETED: " + $PODID + " (timeout, downloaded " + $state.downloaded.Count + ")")
} catch { Log ("pod delete fail: " + $_.Exception.Message) }
$state.done = $true
} else {
Log ("running: " + [math]::Round($runHours,1) + "/" + $MAX_HOURS + "h, downloaded " + $state.downloaded.Count)
}
} catch {
Log ("time parse fail: " + $_.Exception.Message)
}
}
# ---- 持久化状态(纯文本行,PS5.1 可靠)----
try {
$lines = @()
$lines += "pod_start=" + $state.pod_start
$lines += "downloaded=" + ($state.downloaded -join ";")
$lines += ("done=" + $state.done)
$lines += ("started=" + $state.started)
if (Test-Path $STATEFILE) { Remove-Item $STATEFILE -Force }
Add-Content -Path $STATEFILE -Value $lines -Encoding UTF8
} catch {
Log ("state write fail: " + $_.Exception.Message)
}
$state.downloaded | ForEach-Object { Event ("state: downloaded " + $_) }
if ($state.done) { Event ("=== MONITOR DONE: " + $state.downloaded.Count + " checkpoints ===") }
else { Event ("state: running, downloaded=" + $state.downloaded.Count) }
if ($state.done) { Log "=== monitor done ===" }
} finally {
Remove-Item $LOCK -Force -ErrorAction SilentlyContinue
}
+93
View File
@@ -0,0 +1,93 @@
# -*- coding: utf-8 -*-
"""
LoRA checkpoint 验证工具
========================
加载 edit-2511 底模 + 训练好的 LoRA,用多组提示词生成对比图,
验证 LoRA 是否生效、效果如何。
用法:
python validate_lora.py # 验证最新 checkpoint
python validate_lora.py --ckpt <路径> # 验证指定 checkpoint
python validate_lora.py --all # 验证 output/checkpoints 下所有
"""
import glob
import os
import subprocess
import sys
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
VENV = r"D:\AI\sd\musubi-tuner\.venv\Scripts"
PY = os.path.join(VENV, "python.exe")
SRC = r"D:\AI\sd\musubi-tuner\src\musubi_tuner"
MODELS = r"D:\AI\sd\models\qwen-edit-2511"
PROJ = r"D:\F\NewI\opencode\daily-workspace\projects\脸部LoRA训练-Qwen-Image"
TRIGGER = "lm_face_v1"
CKPT_DIR = os.path.join(PROJ, "output", "checkpoints")
OUT_DIR = os.path.join(PROJ, "output", "验证")
# 验证提示词:同一触发词 + 不同场景,看脸是否稳定一致
PROMPTS = [
("证件照", f"{TRIGGER}, professional headshot, business attire, neutral expression, studio lighting, plain white background"),
("日常", f"{TRIGGER}, natural lifestyle portrait, by a window with soft diffused sunlight, warm tones"),
("户外全身", f"{TRIGGER}, full body shot, standing in a park, natural daylight, casual clothing"),
("半侧脸", f"{TRIGGER}, three-quarter view portrait, soft golden hour light, shallow depth of field"),
]
BASE_CMD = [
PY, os.path.join(SRC, "qwen_image_generate_image.py"),
"--dit", os.path.join(MODELS, "transformer", "diffusion_pytorch_model-00001-of-00005.safetensors"),
"--vae", os.path.join(MODELS, "diffusion_pytorch_model.safetensors"),
"--text_encoder", os.path.join(MODELS, "text_encoder", "model-00001-of-00004.safetensors"),
"--model_version", "edit-2511",
"--fp8", "--fp8_scaled", "--blocks_to_swap", "24",
"--infer_steps", "25",
"--image_size", "1024", "1024",
"--seed", "42",
]
def pick_ckpts(args):
if args.ckpt:
return [args.ckpt]
if args.all and os.path.isdir(CKPT_DIR):
return sorted(glob.glob(os.path.join(CKPT_DIR, "*.safetensors")))
ckpts = sorted(glob.glob(os.path.join(CKPT_DIR, "*.safetensors")))
return [ckpts[-1]] if ckpts else []
def main():
import argparse
ap = argparse.ArgumentParser()
ap.add_argument("--ckpt", default=None, help="指定 checkpoint 路径")
ap.add_argument("--all", action="store_true", help="验证所有 checkpoint")
args = ap.parse_args()
ckpts = pick_ckpts(args)
if not ckpts:
print(f"[WARN] 没有找到 checkpoint: {CKPT_DIR}")
sys.exit(1)
os.makedirs(OUT_DIR, exist_ok=True)
print(f"找到 {len(ckpts)} 个 checkpoint,开始验证(每个 4 张图)...")
for ckpt in ckpts:
name = os.path.splitext(os.path.basename(ckpt))[0]
print(f"\n===== {name} =====")
for tag, prompt in PROMPTS:
out = os.path.join(OUT_DIR, f"{name}_{tag}.png")
cmd = BASE_CMD + ["--lora_weight", ckpt, "--lora_multiplier", "0.8",
"--prompt", prompt, "--save_path", out]
print(f" 生成 [{tag}] ...")
r = subprocess.run(cmd)
if r.returncode == 0:
print(f" OK -> {out}")
else:
print(f" FAIL exit={r.returncode}")
print(f"\n验证完成,图片在 {OUT_DIR}")
if __name__ == "__main__":
main()
+16
View File
@@ -0,0 +1,16 @@
@echo off
chcp 65001 >nul
rem ============================================
rem face_checker 一键审核:把照片放进 photos 文件夹,双击本文件
rem ============================================
set TOOL=D:\F\NewI\opencode\daily-workspace\projects\脸部LoRA训练-Qwen-Image\tools\face_checker.py
set PY=D:\AI\sd\musubi-tuner\.venv\Scripts\python.exe
set PHOTOS=D:\F\NewI\opencode\daily-workspace\projects\脸部LoRA训练-Qwen-Image\photos
if not exist "%PHOTOS%" mkdir "%PHOTOS%"
echo 正在审核 %PHOTOS% ...
"%PY%" "%TOOL%" check "%PHOTOS%"
echo.
echo 报告已生成: %PHOTOS%\素材审核报告.html
start "" "%PHOTOS%\素材审核报告.html"
pause
+16
View File
@@ -0,0 +1,16 @@
@echo off
chcp 65001 >nul
rem ============================================
rem face_checker 智能选图:从 photos 里挑出最多样化的 20 张
rem ============================================
set TOOL=D:\F\NewI\opencode\daily-workspace\projects\脸部LoRA训练-Qwen-Image\tools\face_checker.py
set PY=D:\AI\sd\musubi-tuner\.venv\Scripts\python.exe
set PHOTOS=D:\F\NewI\opencode\daily-workspace\projects\脸部LoRA训练-Qwen-Image\photos
if not exist "%PHOTOS%" mkdir "%PHOTOS%"
echo 正在智能选图(推荐 20 张)...
"%PY%" "%TOOL%" pick "%PHOTOS%" --count 20
echo.
echo 推荐报告已生成: %PHOTOS%\智能选图推荐_20张.html
start "" "%PHOTOS%\智能选图推荐_20张.html"
pause
+73
View File
@@ -0,0 +1,73 @@
# face_checker 使用说明(素材自动审核 + 智能选图工具)
## 这是什么
训练脸部 LoRA 前的**素材质检员 + 选图助手**:
- 自动检查每张照片:分辨率、清晰度、人脸数量、人脸大小、正侧脸角度、遮挡、重复图
- 红黄绿三档判定:✅合格 / 🟡警告 / ❌不合格,附具体原因
- **智能选图**:从一堆照片里自动挑出多样化组合(覆盖特写/半身/全身、不同角度、不同风格),你只需要删掉重复的
## 快速开始
用哪个 Pythonmusubi-tuner 的虚拟环境
```powershell
# 场景一:审核照片,生成报告
& "D:\AI\sd\musubi-tuner\.venv\Scripts\python.exe" "D:\F\NewI\opencode\daily-workspace\projects\脸部LoRA训练-Qwen-Image\tools\face_checker.py" check "照片目录"
# 场景二:智能选图(推荐 20 张)
& "D:\AI\sd\musubi-tuner\.venv\Scripts\python.exe" "...\tools\face_checker.py" pick "照片目录" --count 20
# 场景三:审核 + 把合格照片整理成训练集
& "...\face_checker.py" batch "照片目录" --out "训练集目录"
```
> 提示:可以把工具路径存成 PowerShell 变量或做个小 .bat,避免每次打长路径(我可以帮你做)。
## 输出
| 命令 | 产出 |
|---|---|
| `check` | `素材审核报告.html`(浏览器打开看图片墙+判定)+ `素材审核结果.json` |
| `pick` | `智能选图推荐_N张.html`(推荐清单+构图标签)+ `智能选图结果.json` |
| `batch` | 报告 + 训练集文件夹(img_001.jpg 重命名、EXIF 修正、统一 JPG) |
## 判定规则(快查)
| 检查项 | 合格 | 警告 | 不合格 |
|---|---|---|---|
| 分辨率 | 短边 ≥1024 | - | 短边 <1024(微信压缩图) |
| 清晰度 | 人脸区域锐利 | 偏糊 | 明显模糊 |
| 人脸 | 1 张 | 多张脸 / 占比小 / 侧脸 / 疑似遮挡 | 没检测到脸 |
| 重复 | - | 与另一张高度相似 | - |
## 老莫的操作流程
1. 把候选照片**全部**丢进一个文件夹(几十张也没关系,不用自己先筛)
2.`pick --count 20` → 打开推荐报告看一眼,删掉不喜欢的
3.`batch --out 训练集目录` → 自动整理成 `img_001.jpg...`
4. 把训练集目录交给我,我负责打标 caption 和训练
## 注意事项
- **iPhone 的 HEIC 照片**:工具不支持,先转成 JPG(微信发一遍自己或格式转换工具)
- 照片**别用微信压缩过的**(分辨率不够会被判不合格)
- 想多选就 `--count 25`,想少选就 `--count 15`(推荐 15-25 张)
- 重复判定基于视觉相似度,AI 判断可能有漏网,报告里的人工确认最重要
## 参数速查
```
check <目录> [--out 报告目录]
pick <目录> [--count N] [--out 报告目录]
prepare <目录> --out <训练集目录>
batch <目录> --out <训练集目录>
crop <目录> --out <裁切目录> [--scale 1.8] ← 合影处理:按最大人脸裁切单人
```
## 合影与背景处理(老莫须知)
- **合影**:用 `crop` 命令或 GUI 前先裁切——按最大人脸裁出单人(1.8 倍放大)。裁完短边 <1024 的放弃
- **背景**:不要处理!保持自然多样(室内/户外/墙/街景),caption 写清场景即可。**不要抠图换纯色背景**(会导致 LoRA 把纯色背景和脸绑定,出图全是纯背景)
- **禁止**:磨皮美颜、加水印边框文字
- face_checker 的 prepare/整理会自动处理 EXIF 方向 + RGB 转换