脸部LoRA训练项目:素材流水线 + Gradio控制台 + RunPod云端续炼方案(v7续炼完成)
This commit is contained in:
+23
@@ -0,0 +1,23 @@
|
|||||||
|
output/*
|
||||||
|
!output/run_cache.py
|
||||||
|
training/
|
||||||
|
cloud/dataset.zip
|
||||||
|
tools/perf_test/
|
||||||
|
tools/test/
|
||||||
|
*.log
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
tools/gui_state.json
|
||||||
|
tools/gui_dir.txt
|
||||||
|
|
||||||
|
|
||||||
|
# 敏感文件(禁止提交)
|
||||||
|
.runpod_api_key
|
||||||
|
env.local
|
||||||
|
tools/gui_label_backup.json
|
||||||
|
**/gui_label_backup.json
|
||||||
|
|
||||||
|
# 大文件/训练产物
|
||||||
|
temp/
|
||||||
|
*.safetensors
|
||||||
|
cloud/*.zip
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
# 脸部 LoRA 训练项目(Qwen-Rapid-AIO 匹配版)
|
||||||
|
|
||||||
|
> 创建:2026-08-03
|
||||||
|
> 目标:用老莫的照片训练一个脸部 LoRA,让 AI 出图永久自带他的脸,配合常用 checkpoint `Qwen-Rapid-AIO-NSFW-v23` 使用
|
||||||
|
|
||||||
|
## 项目背景
|
||||||
|
|
||||||
|
- 灵感来源:[Z:\wiki\raw\articles\2026-08-02-z-image-turbo-face-lora-训练实操.md](Z-Image-Turbo 路线,已验证技术可信但模型不匹配)
|
||||||
|
- 老莫实际模型:`Qwen-Rapid-AIO-NSFW-v23.safetensors`(26.48GB,位于 `D:\AI\sd\novelai-webui-aki-v3-r\models\Stable-diffusion\`)
|
||||||
|
- 该 checkpoint 本质:HuggingFace `Phr00t/Qwen-Image-Edit-Rapid-AIO` 融合模型 = **Qwen-Image-Edit-2511 底模(20B MMDiT)** + Lightning 4/8 步蒸馏 LoRA + NSFW LoRAs + VAE + Qwen2.5-VL CLIP(FP8)
|
||||||
|
- 结论:LoRA 必须在 **Edit-2511 bf16 官方底模**上训练(不能直接用 FP8 融合模型),训完叠加回 Rapid-AIO 推理
|
||||||
|
|
||||||
|
## 硬件环境
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| GPU | RTX 3080 Laptop GPU **16GB**(Ampere, sm_86) |
|
||||||
|
| 驱动 | 591.74 / CUDA 13.1 |
|
||||||
|
| 内存 | **32GB**(无 64G,靠 fp8 量化 + TE 预缓存压内存) |
|
||||||
|
| 存储 | D 盘 153GB 空闲 |
|
||||||
|
|
||||||
|
## 训练方案(定稿)
|
||||||
|
|
||||||
|
**工具**:kohya musubi-tuner(`D:\AI\sd\musubi-tuner`,v0.3.4)
|
||||||
|
**底模**:Qwen-Image-Edit-2511 bf16(从 ModelScope 下载 diffusers 分片,musubi `load_split_weights` 原生支持分片,无需转换)
|
||||||
|
|
||||||
|
### 核心参数
|
||||||
|
```
|
||||||
|
--model_version edit-2511
|
||||||
|
--network_module networks.lora_qwen_image --network_dim 16
|
||||||
|
--learning_rate 5e-5 --optimizer_type adamw8bit
|
||||||
|
--fp8_base --fp8_scaled --fp8_vl --gradient_checkpointing --blocks_to_swap 32
|
||||||
|
--max_train_epochs 80(≈1600步)
|
||||||
|
--timestep_sampling shift --discrete_flow_shift 2.2
|
||||||
|
分辨率 1024×1024
|
||||||
|
```
|
||||||
|
|
||||||
|
### 内存策略(32G 关键)
|
||||||
|
1. `--fp8_base --fp8_scaled`:DiT 权重 fp8 存储(40GB→20GB)
|
||||||
|
2. 预缓存 text encoder 输出(`qwen_image_cache_text_encoder_outputs.py`):训练时不加载 TE(省 7GB)
|
||||||
|
3. pagefile 设 64G(SSD)当兜底保险
|
||||||
|
4. 训练时关闭浏览器等大内存应用
|
||||||
|
|
||||||
|
### 实测速度(2026-08-05 冒烟实测)
|
||||||
|
| 系统状态 | 速度 | 1600 步预估 |
|
||||||
|
|---|---|---|
|
||||||
|
| ComfyUI 运行中(空闲占 4.6GB VRAM) | **503-670 s/步**(页面抖动,逐步变慢) | **~12 天,不可行** |
|
||||||
|
| 干净系统(初次冒烟) | ~55 s/步 | ~24 小时,隔夜可跑 |
|
||||||
|
|
||||||
|
**启动条件(必须)**:关闭 ComfyUI + 浏览器等大内存程序 → 双击 `启动训练.bat`(bat 有前置检查:ComfyUI 在跑则拒绝启动、commit <30GB 拒绝启动)。
|
||||||
|
**根因**:32GB 物理内存跑 20B fp8 是极限状态,blocks_to_swap 24 的 CPU↔GPU 交换在内存不足时落到 pagefile(SSD),速度跌 10 倍+。训练必须独占物理内存。
|
||||||
|
**共存验证**:ComfyUI 空闲时 VRAM 11.8GB 下训练可跑通(不 OOM),但速度不可接受,仅作应急参考。
|
||||||
|
|
||||||
|
## 文件位置
|
||||||
|
|
||||||
|
| 用途 | 路径 |
|
||||||
|
|---|---|
|
||||||
|
| 训练素材(照片) | `素材/` |
|
||||||
|
| 打标 caption | `打标/` |
|
||||||
|
| 训练配置 | `config/` |
|
||||||
|
| LoRA 输出 | `output/` |
|
||||||
|
| 参考资料 | `资料/` |
|
||||||
|
| musubi-tuner | `D:\AI\sd\musubi-tuner` |
|
||||||
|
| 模型文件 | `D:\AI\sd\models\qwen-edit-2511\`(transformer 5 分片 + text_encoder 4 分片 + vae) |
|
||||||
|
| VAE(已有) | `D:\AI\sd\ComfyUI\ComfyUI_windows_portable_2\ComfyUI\models\vae\qwen_image_vae.safetensors` |
|
||||||
|
|
||||||
|
## 流程
|
||||||
|
|
||||||
|
1. [x] 调研文章 + Qwen 路线验证(2026-08-03)
|
||||||
|
2. [x] 排查本地资源(VAE 已有,DiT/TE 需下载)
|
||||||
|
3. [x] 立项 + musubi-tuner 就位
|
||||||
|
4. [x] 下载模型完成(DiT 38GB + TE 15GB + VAE,ModelScope 8MB/s)
|
||||||
|
5. [x] uv 环境装依赖(cu132:torch 2.13 + CUDA 验证通过)
|
||||||
|
6. [x] 开发 face_checker 辅助工具(审核 + 智能选图,测试通过)
|
||||||
|
7. [ ] 冒烟训练验证(分片加载 + fp8 + swap 内存,3 步小样)
|
||||||
|
8. [ ] 素材准备(老莫选照片 → face_checker pick → batch 整理)
|
||||||
|
9. [ ] 打标(触发词 + 场景描述,不描述五官)
|
||||||
|
10. [ ] 正式训练(1024 + blocks_to_swap 32 + 隔夜)
|
||||||
|
11. [ ] checkpoint 对比(每 10 epoch 存档挑最佳)
|
||||||
|
12. [ ] 叠加 Rapid-AIO 推理验证
|
||||||
|
|
||||||
|
## 辅助工具(全部就绪)
|
||||||
|
|
||||||
|
| 工具 | 位置 | 功能 |
|
||||||
|
|---|---|---|
|
||||||
|
| **auto 流水线** | `tools/face_checker.py auto` | **丢一堆照片 → 自动分类裁剪打标**(见下) |
|
||||||
|
| face_checker | `tools/face_checker.py` | 审核/选图/整理/合影裁切(check/pick/batch/crop) |
|
||||||
|
| **打标 GUI** | `tools/caption_gui.py` | Gradio 网页(localhost:7860),**支持目录切换** |
|
||||||
|
| **融合工具** | `tools/merge_fixed.py` | **原图 + ComfyUI 修改图 无缝合并**(见下) |
|
||||||
|
| 验证脚本 | `tools/validate_lora.py` | 训练后出图对比(备选) |
|
||||||
|
|
||||||
|
### merge_fixed 融合工具(ComfyUI inpainting 后处理)
|
||||||
|
|
||||||
|
**问题**:ComfyUI 移除合影人物时,即使指定"非修改区域不得漂移",全图清晰度仍损失。
|
||||||
|
**方案**:**按修改幅度连续分级融合** —— 有限修改区保留原图像素,明显修改区用修改图像素,中间平滑过渡。
|
||||||
|
|
||||||
|
```
|
||||||
|
python tools\merge_fixed.py <原图目录> <修改图目录> <输出目录> [--alpha-threshold 15] [--softness 6]
|
||||||
|
```
|
||||||
|
|
||||||
|
**核心规则**(老莫定):
|
||||||
|
| 区域 | 修改幅度 | 用谁的像素 |
|
||||||
|
|---|---|---|
|
||||||
|
| 有限修改(放大才看出,如全图降质/面部) | diff 小 | **原图精确像素**(alpha≈0) |
|
||||||
|
| 明显修改(一眼看出,如人物移除) | diff 大 | **修改图像素**(alpha≈1) |
|
||||||
|
| 中间 | 平滑过渡 | sigmoid 渐变(无接缝) |
|
||||||
|
|
||||||
|
- 算法:Lab 色彩空间差异 → 高斯模糊去结构噪声 → **sigmoid 连续 alpha** → 加权混合
|
||||||
|
- **面部保护**(默认开,`--no-protect-face` 关):检测修改图残留人脸,强制原图像素兜底
|
||||||
|
- **验证**:面部 MAE **0.000**(逐像素零改变)、被移除区干净用修改图、过渡无接缝
|
||||||
|
|
||||||
|
**参数**:
|
||||||
|
| 参数 | 默认 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `--alpha-threshold` | 15 | 修改幅度分界。残留残影时调低到 8-10(更多区域归修改图) |
|
||||||
|
| `--softness` | 6 | 过渡带宽度,越大越平缓 |
|
||||||
|
| `--no-protect-face` | - | 关闭面部强制保护 |
|
||||||
|
|
||||||
|
**注意**:残影分两种——① 人物边缘未清除的痕迹(调低 alpha-threshold);② inpainting 区域内部 AI 生成的残留(ComfyUI 质量问题,融合救不了)。
|
||||||
|
|
||||||
|
### auto 全自动流水线(v2,核心!)
|
||||||
|
|
||||||
|
```
|
||||||
|
python tools\face_checker.py auto <照片目录> --out <输出目录> [--limit 20]
|
||||||
|
```
|
||||||
|
|
||||||
|
自动完成:
|
||||||
|
1. **质量评分(0-100)**:人脸绝对像素(40) + 面部清晰度(35) + 分辨率(25) —— **脸小但锐利 > 脸大但糊**
|
||||||
|
2. **清晰度区分**:面部 Laplacian vs 整体 Laplacian(脸糊=淘汰;背景糊但脸清楚=可用)
|
||||||
|
3. **构图分类+自动裁剪**(占比 + 脸像素双指标):
|
||||||
|
- 脸占比 ≥8% **或** 脸短边 ≥256px → 裁**面部特写**(人脸居中放大 1.8 倍)
|
||||||
|
- 脸占比 ≥2% 且脸 ≥150px → 保留**半身**
|
||||||
|
- 检测到完整人体 → 裁**全身构图**
|
||||||
|
- 多人合影 → 自动裁最大人脸;裁后短边不足 → 降级保留原图
|
||||||
|
4. **超量筛选**(--limit 20):每类内部 质量优先 + pHash 多样性贪心,自动挑最优(默认 特写10/半身6/全身4)
|
||||||
|
5. **淘汰**:无人脸/脸糊/脸太小无人体/分辨率不足 → 移入 淘汰/ + 原因
|
||||||
|
6. **打标草稿**:按类型自动生成 caption txt,GUI 里微调
|
||||||
|
|
||||||
|
输出:`out/特写/`、`out/半身/`、`out/全身/`、`out/未选中/`、`out/淘汰/`
|
||||||
|
|
||||||
|
**验证证据**:脸43px极清晰(1260) → 保留;脸大但模糊 → 淘汰。34 张输入 → 自动筛选 12 张最优。
|
||||||
|
|
||||||
|
### 老莫使用流程(最终版)
|
||||||
|
|
||||||
|
1. 照片全丢进 `photos_原始/`(不挑,合影也无所谓)
|
||||||
|
2. 跑 `auto photos_原始 --out photos_auto` → 自动分类裁剪打标
|
||||||
|
3. 打开 http://127.0.0.1:7860 → 顶部输入 `photos_auto` → 加载 → 微调 caption/发型 → 保存
|
||||||
|
4. 一键整理训练集 → 喊我开训
|
||||||
|
|
||||||
|
## 训练自动样本图(已验证)
|
||||||
|
|
||||||
|
- `config/sample_prompts.txt`:4 个验证场景(证件照/日常/户外全身/半侧脸)
|
||||||
|
- 训练脚本已加 `--sample_prompts --sample_every_n_epochs 10`
|
||||||
|
- 每 10 epoch 自动出 4 张样本图到 `output/checkpoints/sample/`,隔天看样本图即可判断 LoRA 演化
|
||||||
|
- 冒烟验证通过(EXIT 0,样本图正常生成)
|
||||||
|
|
||||||
|
## 使用流程(最终版)
|
||||||
|
|
||||||
|
1. 老莫选 15-25 张照片 → 丢进 `photos/`
|
||||||
|
2. 浏览器开 `http://127.0.0.1:7860` → 审核/选类型/填发型/改 caption → 保存 → 一键整理
|
||||||
|
3. 跑 `config\训练脚本.py`(80 epoch ≈ 1600 步 + 自动样本图,约 25 小时隔夜跑)
|
||||||
|
4. 看 `output/checkpoints/sample/` 样本图挑最佳 checkpoint
|
||||||
|
5. checkpoint 复制到 WebUI `models\Lora\qwen\`,加载 Rapid-AIO + LoRA 出图验证
|
||||||
|
|
||||||
|
## 训练验证要点
|
||||||
|
|
||||||
|
- musubi 分片加载:`--dit` 指向 `...-00001-of-00005.safetensors` 自动合并(源码确认)
|
||||||
|
- Edit-2511 无 control image 时**自动降级 T2I 训练**(源码 117-118 行确认)→ 脸部 LoRA 无需编辑图
|
||||||
|
- 数据集 toml **不能有 BOM**(toml 库报错)→ 用无 BOM UTF-8 写
|
||||||
|
- 模型路径保持 ASCII(`D:\AI\sd\models\...`),数据集目录中文路径 PIL 可读
|
||||||
|
|
||||||
|
## 伦理与合规
|
||||||
|
|
||||||
|
- 只训老莫自己的脸,不涉及他人照片
|
||||||
|
- 生成内容公开使用按规标注"AI 生成"
|
||||||
|
|
||||||
|
## 关键结论备忘
|
||||||
|
|
||||||
|
- musubi 支持分片加载:`--dit` 指向 `diffusion_pytorch_model-00001-of-00005.safetensors` 自动合并全部 5 分片(`load_split_weights` 源码确认)
|
||||||
|
- fp8_scaled / fp8_e4m3fn 版本**不能**用于训练(musubi 文档明确)
|
||||||
|
- 训练底模用 bf16 官方权重;推理用 Rapid-AIO(FP8)叠加 LoRA
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# ============================================================
|
||||||
|
# RunPod Secure Cloud 一键引导:Qwen-Image-Edit-2511 脸部 LoRA 训练
|
||||||
|
# 用法:pod 启动后,在 web terminal 里执行:
|
||||||
|
# cd /workspace && unzip dataset.zip -d train_dataset && bash bootstrap.sh
|
||||||
|
# ============================================================
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "===== [1/4] 安装 musubi-tuner ====="
|
||||||
|
cd /workspace
|
||||||
|
if [ ! -d musubi-tuner ]; then
|
||||||
|
git clone --depth 1 https://github.com/kohya-ss/musubi-tuner.git
|
||||||
|
fi
|
||||||
|
cd musubi-tuner
|
||||||
|
pip install -e . --quiet
|
||||||
|
pip install --quiet huggingface_hub hf_transfer
|
||||||
|
export HF_HUB_ENABLE_HF_TRANSFER=1
|
||||||
|
|
||||||
|
echo "===== [2/4] 下载模型(Qwen-Image-Edit-2511,机房网速约2-5分钟)====="
|
||||||
|
mkdir -p /workspace/models/qwen-edit-2511/{transformer,text_encoder}
|
||||||
|
hf download Qwen/Qwen-Image-Edit-2511 \
|
||||||
|
--include "transformer/*" \
|
||||||
|
--local-dir /workspace/models/qwen-edit-2511
|
||||||
|
hf download Qwen/Qwen-Image-Edit-2511 \
|
||||||
|
--include "text_encoder/*" \
|
||||||
|
--local-dir /workspace/models/qwen-edit-2511
|
||||||
|
hf download Qwen/Qwen-Image-Edit-2511 \
|
||||||
|
--include "vae/diffusion_pytorch_model.safetensors" \
|
||||||
|
--local-dir /workspace/models/qwen-edit-2511
|
||||||
|
# VAE 路径兼容(musubi 参数直接指到文件)
|
||||||
|
find /workspace/models/qwen-edit-2511 -name "*.safetensors" | head -20
|
||||||
|
|
||||||
|
echo "===== [3/4] 数据集配置 ====="
|
||||||
|
mkdir -p /workspace/train_dataset /workspace/config /workspace/ckpt
|
||||||
|
cat > /workspace/config/dataset.toml <<'EOF'
|
||||||
|
[general]
|
||||||
|
resolution = 1024
|
||||||
|
caption_extension = ".txt"
|
||||||
|
batch_size = 1
|
||||||
|
enable_bucket = true
|
||||||
|
bucket_no_upscale = false
|
||||||
|
|
||||||
|
[[datasets]]
|
||||||
|
image_directory = "/workspace/train_dataset"
|
||||||
|
num_repeats = 1
|
||||||
|
EOF
|
||||||
|
cp /workspace/sample_prompts.txt /workspace/config/ 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "===== [3.5] 续炼 checkpoint 就位 ====="
|
||||||
|
# 续炼起点:本地 scp 上传的 myface_lora-000060.safetensors 在 /workspace/
|
||||||
|
# (1.1GB,dim32 版本),移到 /workspace/ckpt/ 供 --network_weights 使用
|
||||||
|
if [ -f /workspace/myface_lora-000060.safetensors ]; then
|
||||||
|
cp /workspace/myface_lora-000060.safetensors /workspace/ckpt/
|
||||||
|
echo "checkpoint 就位: $(ls -la /workspace/ckpt/myface_lora-000060.safetensors)"
|
||||||
|
else
|
||||||
|
echo "WARNING: /workspace/myface_lora-000060.safetensors 不存在!续炼会失败(除非从头训练)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "===== [4/4] 预缓存(VAE latent + TextEncoder)====="
|
||||||
|
MODELS=/workspace/models/qwen-edit-2511
|
||||||
|
DIT=$MODELS/transformer/$(ls $MODELS/transformer | grep '00001-of' | head -1)
|
||||||
|
TE=$MODELS/text_encoder/$(ls $MODELS/text_encoder | grep '00001-of' | head -1)
|
||||||
|
VAE=$(find $MODELS/vae -name "*.safetensors" | head -1)
|
||||||
|
echo "DIT: $DIT"
|
||||||
|
echo "TE: $TE"
|
||||||
|
echo "VAE: $VAE"
|
||||||
|
|
||||||
|
python src/musubi_tuner/qwen_image_cache_latents.py \
|
||||||
|
--dataset_config /workspace/config/dataset.toml \
|
||||||
|
--vae "$VAE" --device cuda --model_version edit-2511
|
||||||
|
|
||||||
|
python src/musubi_tuner/qwen_image_cache_text_encoder_outputs.py \
|
||||||
|
--dataset_config /workspace/config/dataset.toml \
|
||||||
|
--text_encoder "$TE" --fp8_vl --device cuda --model_version edit-2511
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "===== 全部就绪!启动训练执行:bash train.sh ====="
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCmWjWSUaZFn3DD0MX3W7+BkTDoJvC+ssWYlxBFrMSB3MOm3HpBMySB1BBBy6J3tEcEPP+zngZFuSTBWvSOaUMXCcvkd4RKFkGzhRjLIRN0yrMI4EH2KoObv7hTSvma4R0DUQoFp/GuFu8dNbZOfcfGKFtBAfvf2mynkod/RDNd89K9pqpJxzWgBdYAuHgVelXiF+hnG34zH+w6EXQmk0+91YDdHQX9F8yszE561ATNOxvPpAIETuo9tNNaOlS7FnZUR0vva6AzMY7/hlkxVIZGwRwayzdRUG8VGh0nLkwc7o9MFW4HiUq+WtQTgePPTJ3RuNBNVi8NdeLfr6uT5dh5xmq6QAWuN7OjZkb4fPYe7RETDSukMdaqv9YtjwYKTRuva+JuAEJdgZr6Ar8jXAh7k63hKQWNCtIp3QEnbn79HOCsRpweFcc77Z3JmFLRU4JLBp3iqmtZAniz465JOsjhIHdfHVWS1hP7S2oIe/GDwL6ts30clOTNctJlF43xWa0FoRN83JZgKaXghiBoT1AX9gK0Y5uZYCeN9/qJkdq4TJtFWO16pGFKmvE2OyIGr9q+tLEVUsxqjoFTlaUoqHBRTAq+danZfv7wLTnjX8ZTTXHvON2ethJInt6vRhdxz2fgH77pZP/3mZLbd9h8YGQCxyxkLO898nn32wu2SUXDYw== hmo@daily-workspace
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# 训练时自动出样本图(验证 LoRA 演化,每行一个 prompt,# 开头是注释)
|
||||||
|
lm_face_v1, professional headshot, business attire, neutral expression, studio lighting, plain white background
|
||||||
|
lm_face_v1, natural lifestyle portrait, by a window with soft diffused sunlight, warm tones
|
||||||
|
lm_face_v1, full body shot, standing in a park, natural daylight, casual clothing
|
||||||
|
lm_face_v1, three-quarter view portrait, soft golden hour light, shallow depth of field
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# ============================================================
|
||||||
|
# 正式训练(RunPod Secure Cloud 4090 24G)
|
||||||
|
# 用法:nohup bash train.sh > /workspace/train.log 2>&1 &
|
||||||
|
# 特性:崩溃自动重启(进程退出后自动拉起续跑,直到完成或 epoch120 checkpoint 出现)
|
||||||
|
# ============================================================
|
||||||
|
cd /workspace/musubi-tuner
|
||||||
|
MODELS=/workspace/models/qwen-edit-2511
|
||||||
|
DIT=$MODELS/transformer/$(ls $MODELS/transformer | grep '00001-of' | head -1)
|
||||||
|
TE=$MODELS/text_encoder/$(ls $MODELS/text_encoder | grep '00001-of' | head -1)
|
||||||
|
VAE=$(find $MODELS/vae -name "*.safetensors" | head -1)
|
||||||
|
|
||||||
|
TRAIN_CMD="accelerate launch --num_cpu_threads_per_process 2 --mixed_precision bf16 \
|
||||||
|
src/musubi_tuner/qwen_image_train_network.py \
|
||||||
|
--dit \"$DIT\" --vae \"$VAE\" --text_encoder \"$TE\" \
|
||||||
|
--model_version edit-2511 \
|
||||||
|
--dataset_config /workspace/config/dataset.toml \
|
||||||
|
--sdpa --mixed_precision bf16 \
|
||||||
|
--timestep_sampling shift --weighting_scheme none --discrete_flow_shift 2.2 \
|
||||||
|
--optimizer_type adamw8bit --learning_rate 1e-4 \
|
||||||
|
--gradient_checkpointing \
|
||||||
|
--network_module networks.lora_qwen_image --network_dim 32 \\
|
||||||
|
--network_weights /workspace/ckpt/myface_lora-000060.safetensors \
|
||||||
|
--fp8_base --fp8_scaled --fp8_vl --blocks_to_swap 8 \
|
||||||
|
--max_train_epochs 50 --save_every_n_epochs 10 --seed 42 \
|
||||||
|
--sample_prompts /workspace/config/sample_prompts.txt --sample_every_n_epochs 10 \
|
||||||
|
--output_dir /workspace/ckpt --output_name myface_v7"
|
||||||
|
|
||||||
|
# 崩溃自动重启:最多 20 次(防止死循环烧钱)
|
||||||
|
for attempt in $(seq 1 20); do
|
||||||
|
echo "===== [尝试 $attempt/20] 启动训练 $(date) ====="
|
||||||
|
eval "$TRAIN_CMD"
|
||||||
|
rc=$?
|
||||||
|
echo "===== 训练退出 rc=$rc $(date) ====="
|
||||||
|
# 完成检测:epoch120 checkpoint 已存在 → 训练成功结束
|
||||||
|
if ls /workspace/ckpt/myface_v7-000050.safetensors >/dev/null 2>&1; then
|
||||||
|
echo "===== epoch120 checkpoint 已生成,训练完成 ====="
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
# 中途崩溃 → 等待 30s 后自动重启续跑
|
||||||
|
echo "===== 训练中断(rc=$rc),30 秒后自动重启续跑 ====="
|
||||||
|
sleep 30
|
||||||
|
done
|
||||||
|
echo "===== train.sh 结束 $(date) ====="
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# musubi-tuner 数据集配置(脸部 LoRA / Edit-2511 T2I)
|
||||||
|
# 无 control_directory → musubi 自动按 text-to-image 训练
|
||||||
|
|
||||||
|
[general]
|
||||||
|
resolution = 1024
|
||||||
|
caption_extension = ".txt"
|
||||||
|
batch_size = 1
|
||||||
|
enable_bucket = true
|
||||||
|
bucket_no_upscale = false
|
||||||
|
|
||||||
|
[[datasets]]
|
||||||
|
image_directory = "D:/F/NewI/opencode/daily-workspace/projects/脸部LoRA训练-Qwen-Image/output/train_dataset"
|
||||||
|
num_repeats = 1
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# 由 ⑤ 训练 Tab 重新预缓存生成
|
||||||
|
[general]
|
||||||
|
resolution = 1024
|
||||||
|
caption_extension = ".txt"
|
||||||
|
batch_size = 1
|
||||||
|
enable_bucket = true
|
||||||
|
bucket_no_upscale = false
|
||||||
|
|
||||||
|
[[datasets]]
|
||||||
|
image_directory = "K:/AI/training/ldf/singled/5. autohandling_v7/train_dataset"
|
||||||
|
num_repeats = 1
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# musubi-tuner 冒烟测试数据集配置
|
||||||
|
[general]
|
||||||
|
resolution = 1024
|
||||||
|
caption_extension = ".txt"
|
||||||
|
batch_size = 1
|
||||||
|
enable_bucket = true
|
||||||
|
bucket_no_upscale = false
|
||||||
|
|
||||||
|
[[datasets]]
|
||||||
|
image_directory = "D:/F/NewI/opencode/daily-workspace/projects/脸部LoRA训练-Qwen-Image/output/smoke_dataset"
|
||||||
|
num_repeats = 1
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# 训练时自动出样本图(验证 LoRA 演化,每行一个 prompt,# 开头是注释)
|
||||||
|
lm_face_v1, professional headshot, business attire, neutral expression, studio lighting, plain white background
|
||||||
|
lm_face_v1, natural lifestyle portrait, by a window with soft diffused sunlight, warm tones
|
||||||
|
lm_face_v1, full body shot, standing in a park, natural daylight, casual clothing
|
||||||
|
lm_face_v1, three-quarter view portrait, soft golden hour light, shallow depth of field
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"train_dataset": "K:\\AI\\training\\ldf\\singled\\5. autohandling_v7\\train_dataset",
|
||||||
|
"output_dir": "K:\\AI\\training\\ldf\\singled\\5. autohandling_v7\\output\\checkpoints"
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# 脸部 LoRA 训练脚本(Qwen-Image Edit-2511 / 16G 显存 / 32G 内存优化版)
|
||||||
|
# 用法:powershell -ExecutionPolicy Bypass -File 训练脚本.ps1
|
||||||
|
# 前置:素材已整理到 output/train_dataset/(img_001.jpg + img_001.txt 打标)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$env:PYTHONUTF8 = "1"
|
||||||
|
|
||||||
|
$VENV = "D:\AI\sd\musubi-tuner\.venv\Scripts"
|
||||||
|
$TOOL = "D:\AI\sd\musubi-tuner\src\musubi_tuner"
|
||||||
|
$MODELS = "D:\AI\sd\models\qwen-edit-2511"
|
||||||
|
$PROJ = "D:\F\NewI\opencode\daily-workspace\projects\脸部LoRA训练-Qwen-Image"
|
||||||
|
|
||||||
|
$env:HF_HUB_OFFLINE = "1" # 全程离线,避免联网
|
||||||
|
|
||||||
|
& "$VENV\accelerate.exe" launch --num_cpu_threads_per_process 1 --mixed_precision bf16 `
|
||||||
|
"$TOOL\qwen_image_train_network.py" `
|
||||||
|
--dit "$MODELS\transformer\diffusion_pytorch_model-00001-of-00005.safetensors" `
|
||||||
|
--vae "$MODELS\diffusion_pytorch_model.safetensors" `
|
||||||
|
--text_encoder "$MODELS\text_encoder\model-00001-of-00004.safetensors" `
|
||||||
|
--model_version edit-2511 `
|
||||||
|
--dataset_config "$PROJ\config\dataset.toml" `
|
||||||
|
--sdpa --mixed_precision bf16 `
|
||||||
|
--timestep_sampling shift --weighting_scheme none --discrete_flow_shift 2.2 `
|
||||||
|
--optimizer_type adamw8bit --learning_rate 5e-5 `
|
||||||
|
--gradient_checkpointing `
|
||||||
|
--network_module networks.lora_qwen_image --network_dim 16 `
|
||||||
|
--fp8_base --fp8_scaled --fp8_vl --blocks_to_swap 32 `
|
||||||
|
--max_train_epochs 80 --save_every_n_epochs 10 --seed 42 `
|
||||||
|
--output_dir "$PROJ\output\checkpoints" --output_name myface_lora
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
正式训练脚本:脸部 LoRA(Qwen-Image Edit-2511 / 16G 显存 / 32G 内存优化)
|
||||||
|
用法: python 训练脚本.py
|
||||||
|
前置: output/train_dataset/ 里有 img_001.jpg + img_001.txt 打标
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
os.environ["PYTHONUTF8"] = "1"
|
||||||
|
os.environ["HF_HUB_OFFLINE"] = "1"
|
||||||
|
|
||||||
|
VENV = r"M:\AI\sd\musubi-tuner\.venv\Scripts"
|
||||||
|
PROJ = r"D:\F\NewI\opencode\daily-workspace\projects\脸部LoRA训练-Qwen-Image"
|
||||||
|
MODELS = r"M:\AI\sd\models\qwen-edit-2511"
|
||||||
|
|
||||||
|
# 动态目录配置(GUI 训练 Tab 写入 config/train_config.json)
|
||||||
|
_cfg = {}
|
||||||
|
_cfg_path = os.path.join(PROJ, "config", "train_config.json")
|
||||||
|
if os.path.exists(_cfg_path):
|
||||||
|
try:
|
||||||
|
_cfg = json.load(open(_cfg_path, encoding="utf-8-sig")) # utf-8-sig 处理 BOM
|
||||||
|
except Exception as _e:
|
||||||
|
print(f"[WARN] train_config.json 读取失败: {_e}")
|
||||||
|
_cfg = {}
|
||||||
|
TRAIN_DATASET = _cfg.get("train_dataset", os.path.join(PROJ, "output", "train_dataset"))
|
||||||
|
CKPT_OUT = _cfg.get("output_dir", os.path.join(PROJ, "output", "checkpoints"))
|
||||||
|
|
||||||
|
# 动态生成数据集配置(image_directory 指向所选训练集)
|
||||||
|
_dataset_toml = os.path.join(PROJ, "config", "dataset_active.toml")
|
||||||
|
_toml_body = f"""# 由 GUI 训练 Tab 动态生成(勿手改)
|
||||||
|
[general]
|
||||||
|
resolution = 1024
|
||||||
|
caption_extension = ".txt"
|
||||||
|
batch_size = 1
|
||||||
|
enable_bucket = true
|
||||||
|
bucket_no_upscale = false
|
||||||
|
|
||||||
|
[[datasets]]
|
||||||
|
image_directory = "{TRAIN_DATASET.replace(chr(92), '/')}"
|
||||||
|
num_repeats = 1
|
||||||
|
"""
|
||||||
|
with open(_dataset_toml, "w", encoding="utf-8") as _f:
|
||||||
|
_f.write(_toml_body)
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
os.path.join(VENV, "accelerate.exe"), "launch",
|
||||||
|
"--num_cpu_threads_per_process", "1", "--mixed_precision", "bf16",
|
||||||
|
r"D:\AI\sd\musubi-tuner\src\musubi_tuner\qwen_image_train_network.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",
|
||||||
|
"--dataset_config", _dataset_toml,
|
||||||
|
"--sdpa", "--mixed_precision", "bf16",
|
||||||
|
"--timestep_sampling", "shift", "--weighting_scheme", "none", "--discrete_flow_shift", "2.2",
|
||||||
|
"--optimizer_type", "adamw8bit", "--learning_rate", "5e-5",
|
||||||
|
"--gradient_checkpointing",
|
||||||
|
"--network_module", "networks.lora_qwen_image", "--network_dim", "16",
|
||||||
|
# 续炼:加载旧 LoRA 000060(脸已成形、细节未过度固化),用重调后的 v7 数据增量修正标签
|
||||||
|
"--network_weights", r"M:\AI\sd\novelai-webui-aki-v3-r\models\Lora\v6\myface_lora-000060.safetensors",
|
||||||
|
"--fp8_base", "--fp8_scaled", "--fp8_vl", "--blocks_to_swap", "24",
|
||||||
|
"--max_train_epochs", "50", "--save_every_n_epochs", "10", "--seed", "42",
|
||||||
|
"--sample_prompts", os.path.join(PROJ, "config", "sample_prompts.txt"),
|
||||||
|
"--sample_every_n_epochs", "10",
|
||||||
|
"--output_dir", CKPT_OUT,
|
||||||
|
"--output_name", "myface_v7",
|
||||||
|
]
|
||||||
|
print("训练集:", TRAIN_DATASET)
|
||||||
|
print("输出目录:", CKPT_OUT)
|
||||||
|
print("CMD:", " ".join(cmd))
|
||||||
|
r = subprocess.run(cmd)
|
||||||
|
print("EXIT CODE:", r.returncode)
|
||||||
|
sys.exit(r.returncode)
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
# 脸部 LoRA 云端训练 · 完整操作文档(v2 复盘 + v3 方案)
|
||||||
|
|
||||||
|
> 建立:2026-08-09
|
||||||
|
> 性质:**训练全流程唯一权威参考**(含上次全部踩坑记录)
|
||||||
|
> 铁律:**训练期间不要手动干预;监控全自动;用户睡觉期间 AI 不执行任何未经文档确认的操作**
|
||||||
|
|
||||||
|
## 本次训练实况(v3 最终版,2026-08-09)
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| pod id | `k1nzrqh8lfj7jg`(**RTX 4090** 24GB,$0.74/h) |
|
||||||
|
| IP/SSH | 103.196.86.68 / 50155 |
|
||||||
|
| 镜像 | runpod/pytorch:2.4.0-py3.11-cuda12.4.1-devel-ubuntu22.04 |
|
||||||
|
| blocks_to_swap | **8**(4090 24GB,与 v2 一致) |
|
||||||
|
| 训练 | dim32/lr1e-4/120ep/32张=3840步,实测 **2.5s/步**,ETA ~2.5h |
|
||||||
|
| 预缓存 | ✅ 已验证 128 文件(32 img + 32 txt + 32 VAE `_qie` + 32 TE `_qie_te`) |
|
||||||
|
| 监控 | monitor_v3 + 计划任务 FaceLoRA-Monitor-v3(每5分钟) |
|
||||||
|
| 下载 | `https://k1nzrqh8lfj7jg-8888.proxy.runpod.net/`(HTTP 200 已验证) |
|
||||||
|
| 预算 | max_hours 4,$0.74/h × 4 = **$2.96 封顶** |
|
||||||
|
|
||||||
|
> ⚠️ **GPU 选择结论(踩坑记录,2026-08-09)**:
|
||||||
|
> - ❌ **RTX 5090(Blackwell sm_120)不可用**——镜像 PyTorch 2.4 只支持到 sm_90,VAE 预缓存 GPU 编码直接失败(`sm_120 not compatible`),浪费 55GB 下载 + 烧钱
|
||||||
|
> - ❌ **A40 可用但慢**——实测 5.33s/步(算力 ~37 TFLOPS,是 4090 一半)
|
||||||
|
> - ✅ **RTX 4090 最优**——实测 **2.5s/步**,且是 v2 完整验证过的(上次跑完 2400 步)
|
||||||
|
> - **教训**:换 GPU 前必须确认「镜像 PyTorch 的 CUDA capability ≤ GPU 架构」!5090=sm_120、4090/A40=sm_86/89(PyTorch 2.4 支持 sm_50-90)
|
||||||
|
|
||||||
|
> ⚠️ **预缓存验证方法(踩坑记录)**:Qwen 的缓存文件是 **`.safetensors`(`img_xxx_..._qie.safetensors` + `_qie_te.safetensors`),不是 `.npz`**!判断缓存成功要看 `_qie.safetensors` 数量 = 素材数,别查 .npz(会误判为 0 导致误删/重复折腾)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、项目目标与当前状态
|
||||||
|
|
||||||
|
**目标**:用老莫(用户)老婆的照片训练脸部 LoRA,叠加 `Qwen-Rapid-AIO-NSFW-v23` checkpoint 出图。
|
||||||
|
|
||||||
|
**训练底模**:Qwen-Image-Edit-2511 bf16 官方底模(不能用 FP8 融合模型训练)。
|
||||||
|
|
||||||
|
**当前素材状态**(2026-08-09):
|
||||||
|
- 已选 32 张(特写 21 / 半身 8 / 全身 3),无冗余(已删重复)
|
||||||
|
- 大笑 3 张(稀缺)、表情 6 种、光照多样
|
||||||
|
- 训练集已生成:`K:\AI\training\ldf\singled\5. autohandling_v6\train_dataset\`(img_001..032 + txt)
|
||||||
|
- dataset.zip 已打包:`cloud/dataset.zip`(20.8MB,32 img + 32 txt = 64 文件)
|
||||||
|
- 余额:**$10 已充值**(预计本次花费 $3-4,足够)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、训练参数(最终定稿)
|
||||||
|
|
||||||
|
| 参数 | 值 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| network_dim | 32 | v1(dim16) 验证"不像"→ 调大;v2 未验证效果 |
|
||||||
|
| learning_rate | 1e-4 | 与 dim32 配套 |
|
||||||
|
| max_train_epochs | 120 | 32 张 × 120 = **3840 步** |
|
||||||
|
| blocks_to_swap | 8 | 4090 24GB + 62GB RAM 够用 |
|
||||||
|
| fp8_base/scaled/vl | on | 省显存 |
|
||||||
|
| seed | 42 | 可复现 |
|
||||||
|
| save_every_n_epochs | 10 | 每 10 epoch 存 checkpoint(12 个) |
|
||||||
|
| sample_every_n_epochs | 10 | 每 10 epoch 出 4 张样本图 |
|
||||||
|
| 分辨率 | 1024 | |
|
||||||
|
|
||||||
|
**云端 GPU**:**RTX 4090 Secure Cloud $0.74/h**(A40 无容量,最后用 4090 成功)
|
||||||
|
**预计时长**:3840 步 × ~3.3s/步 ≈ **3.5-4 小时**,花费 **~$3**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、云端 pod 全流程(v2 实测验证,脚本化)
|
||||||
|
|
||||||
|
### 3.1 创建 pod(RunPod REST API)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$proj = "D:\F\NewI\opencode\daily-workspace\projects\脸部LoRA训练-Qwen-Image"
|
||||||
|
$key = Get-Content "$proj\.runpod_api_key"
|
||||||
|
$body = @{
|
||||||
|
name = "face-lora-train-v3"
|
||||||
|
imageName = "runpod/pytorch:2.4.0-py3.11-cuda12.4.1-devel-ubuntu22.04"
|
||||||
|
gpuTypeIds = @("NVIDIA GeForce RTX 4090")
|
||||||
|
gpuCount = 1
|
||||||
|
cloudType = "SECURE" # 铁律:只用 Secure,禁 Community
|
||||||
|
containerDiskInGb = 100
|
||||||
|
volumeInGb = 0
|
||||||
|
} | ConvertTo-Json
|
||||||
|
$r = Invoke-RestMethod -Uri "https://rest.runpod.io/v1/pods" -Method Post `
|
||||||
|
-Headers @{Authorization = "Bearer $key"; "Content-Type" = "application/json"} -Body $body -TimeoutSec 60
|
||||||
|
"pod id: $($r.id) | cost/h: $($r.costPerHr)"
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键点**:
|
||||||
|
- **API key 位置**:`$proj\.runpod_api_key`(`C:\Users\hmo\.runpod_api_key` 不存在,别用错)
|
||||||
|
- **A40 曾无容量**(stuck provisioning)→ 4090 是最终验证可行的
|
||||||
|
- 创建后轮询 pod 状态直到 `runtime` 非空(拿到 IP + SSH 端口 + Jupyter 端口 + 密码)
|
||||||
|
|
||||||
|
### 3.2 等待就绪 + 拿连接信息
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Start-Sleep 60 # 后台等待(铁律:禁止前台 sleep 轮询,分两步)
|
||||||
|
$r = Invoke-RestMethod -Uri "https://rest.runpod.io/v1/pods/$PODID" -Headers @{Authorization = "Bearer $key"} -TimeoutSec 30
|
||||||
|
if ($r.runtime) {
|
||||||
|
"publicIp: $($r.runtime.publicIp)"
|
||||||
|
$r.runtime.ports | ForEach-Object { "private:$($_.privatePort) -> public:$($_.publicPort)" }
|
||||||
|
"jupyterPass: $($r.runtime.jupyterPassword)"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 ⚠️ SSH key 修复(每次新 pod 必做,最大的坑)
|
||||||
|
|
||||||
|
**背景**:RunPod Settings 里注册的 key 是错误的(指纹 `I2i//`,与本地私钥 `7Eep5Qz` 不匹配)。新 pod 的 `PUBLIC_KEY` 环境变量注入的是错误 key → SSH 永远 Permission denied。
|
||||||
|
|
||||||
|
**修复**(需用户在 RunPod Web Terminal 手动执行一次):
|
||||||
|
```bash
|
||||||
|
curl -sk -u xiaoxiao:fXmRReiHMnY3AVB "https://git.yoin.fun/api/v1/repos/xiaoxiao/face-lora-qwen-image/raw/cloud/correct_key.pub" -o /root/.ssh/authorized_keys && chmod 600 /root/.ssh/authorized_keys && service ssh restart && ssh-keygen -lf /root/.ssh/authorized_keys
|
||||||
|
```
|
||||||
|
成功标志:指纹显示 `7Eep5QzBfPDzvTgHfNMOC5TPOmW+zSbhHYLctKmkX5Y`(与本地 `id_rsa.pub` 一致)。
|
||||||
|
|
||||||
|
**正确 key 已存**:`cloud/correct_key.pub`(Gitea 仓库里也有,可 curl 拉取)。
|
||||||
|
|
||||||
|
### 3.4 上传 4 个文件 + 解压 + 启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 本地 scp 上传(注意:目标路径含中文会坑,用 ASCII 中转或分步)
|
||||||
|
scp -i $KEY -P $PORT cloud/dataset.zip cloud/bootstrap.sh cloud/train.sh cloud/sample_prompts.txt root@$IP:/workspace/
|
||||||
|
|
||||||
|
# SSH 进 pod 后:
|
||||||
|
cd /workspace
|
||||||
|
unzip dataset.zip -d train_dataset # pod 上可能没有 unzip → 用 python -m zipfile -e 代替
|
||||||
|
bash bootstrap.sh # 装 musubi + 下 55G 模型 + 预缓存,10-15 分钟
|
||||||
|
nohup bash train.sh > /workspace/train.log 2>&1 & # 后台训练
|
||||||
|
```
|
||||||
|
|
||||||
|
**⚠️ SSH 后台任务坑**:`nohup ... &` 直接跑会因 SSH session 挂起。正确:`setsid nohup bash train.sh > /workspace/train.log 2>&1 < /dev/null &`(三流全重定向)。
|
||||||
|
|
||||||
|
### 3.5 checkpoint 下载(HTTP 代理,不用 scp)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# pod 上启动 http.server(替代 Jupyter 占用的 8888)
|
||||||
|
python3 -m http.server 8888 --directory /workspace/ckpt
|
||||||
|
|
||||||
|
# 本地 aria2c 多线程下载(验证过:aria2c 1.37.0 可用)
|
||||||
|
aria2c -x16 -s16 -k1M -d "local_dir" -o "myface_lora-000050.safetensors" "https://$PODID-8888.proxy.runpod.net/myface_lora-000050.safetensors"
|
||||||
|
```
|
||||||
|
|
||||||
|
**下载 URL 格式**:`https://<podid>-8888.proxy.runpod.net/<filename>`
|
||||||
|
**checkpoint 大小**:~563MB 每个(v2 实测)
|
||||||
|
**校验**:完整文件 = 590153736 字节(562.8MB),小于此值 = 不完整,删了重下。
|
||||||
|
|
||||||
|
### 3.6 收尾:删除 pod
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Invoke-RestMethod -Uri "https://rest.runpod.io/v1/pods/$PODID" -Method Delete -Headers @{Authorization = "Bearer $key"} -TimeoutSec 30
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、⚠️ 上次 v2 训练完整翻车复盘(必须吸取)
|
||||||
|
|
||||||
|
### 时间线(8/6 23:00 - 8/7 07:55,用户睡觉期间)
|
||||||
|
|
||||||
|
1. **23:59** 创建 pod `f0fhlwz7rrhma6`(4090 Secure $0.74/h)
|
||||||
|
2. **00:02** 用户 Web Terminal 修 key → SSH 连上 → 上传 → bootstrap
|
||||||
|
3. **00:10** 训练启动(2400 步,3.3s/步,预计 2.2h)
|
||||||
|
4. **00:23** 用户睡觉。我启动 monitor_v2.ps1(SSH 轮询)
|
||||||
|
5. **00:26** 训练正常:261/2400,loss 0.0601
|
||||||
|
6. **07:54** 用户醒来质问:"你在干什么?"
|
||||||
|
- **SSH 连不上**(banner exchange refused)
|
||||||
|
- **pod 没了**(0 pods)→ **余额耗尽,pod 被 RunPod 强制终止**
|
||||||
|
- monitor 日志 **steps=? 一直解析失败**(bug)→ 没检测到余额耗尽,没抢救
|
||||||
|
7. **08:00** 确认:**训练其实完整跑完了**(samples 48 张 = e010-e120 全有)!但 **checkpoint 一个没下载**(monitor 只等 e120 后下载,且下载逻辑没触发)
|
||||||
|
|
||||||
|
### 根因(三条,全部是 monitor 脚本 bug)
|
||||||
|
|
||||||
|
| # | Bug | 后果 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | `tail -2` 抓 tqdm 进度 → 正则失败,steps 永远 `?` | 进度不可见,异常检测失效 |
|
||||||
|
| 2 | 下载只在 e120 出现后一次性执行,且用 scp(中文路径坑) | 中途不下载,崩溃时抢救也失败 |
|
||||||
|
| 3 | **无余额监控** | 余额耗尽 pod 被删,checkpoint 全丢 |
|
||||||
|
|
||||||
|
### 其他踩坑(8/7 全天)
|
||||||
|
|
||||||
|
- **scp 目标路径含中文**(`projects\脸部LoRA训练-Qwen-Image`)→ 文件写入失败。解决:用 HTTP 代理下载
|
||||||
|
- **ssh 后台任务挂起 session**:`nohup &` 不够,要 `setsid nohup ... </dev/null >log 2>&1 &`
|
||||||
|
- **image_to_text.py(SenseNova)用 musubi venv python 跑**(系统 python PATH 坏了)
|
||||||
|
- **OpenCV 读不了中文路径的 ONNX 模型** → 拷贝到 %TEMP% ASCII 路径
|
||||||
|
- **GUI 每次改代码要升版本号**(页面标题 + 终端都显示,验证重启生效)
|
||||||
|
- **前后台铁律**:禁止前台 sleep/轮询;长期任务必须后台
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、v3 改进方案(本次要用,monitor_v3.ps1 已写好)
|
||||||
|
|
||||||
|
### 核心变化:从"SSH 轮询"改为"HTTP 文件轮询 + 状态持久化 + 计划任务"
|
||||||
|
|
||||||
|
| 项 | v2(失败) | v3(本次) |
|
||||||
|
|---|---|---|
|
||||||
|
| 调度 | 手动 while 循环 | **Windows 计划任务每 5 分钟** + PID 锁 |
|
||||||
|
| 进度信号 | SSH 解析 tqdm(失败) | **云端 checkpoint 文件出现**(HTTP HEAD) |
|
||||||
|
| 下载 | 只等 e120 一次性 | **增量下载**:每个 epoch 出现立即下 |
|
||||||
|
| 余额 | 无 | **时长止损**:超预算自动删 pod |
|
||||||
|
| 崩溃 | 依赖进度解析(失效) | checkpoint mtime 超 45 分钟未更新 → 抢救 |
|
||||||
|
| 状态 | 无 | `monitor_state.txt` 持久化(已下载清单) |
|
||||||
|
| 通知 | 无 | 只写日志(用户明确不要微信/通知) |
|
||||||
|
|
||||||
|
### 监控脚本(正式版)
|
||||||
|
|
||||||
|
**`projects/脸部LoRA训练-Qwen-Image/tools/monitor_train_v3.ps1`**(计划任务指向此文件)
|
||||||
|
- 读 `temp/train_env.json`(pod_id/dl_url/ssh_host/ssh_port/max_hours/total_steps)
|
||||||
|
- PID 锁防重(计划任务重复触发安全)
|
||||||
|
- 每次运行做一轮:列云端 checkpoint → 增量下载 → 完成判定 → 崩溃判定 → 超时止损 → 写状态
|
||||||
|
- **所有输出在纯 ASCII 目录** `C:\Users\hmo\AppData\Local\Temp\opencode\face_lora\`(monitor_v3.log / train_events.log / monitor_state.txt / checkpoints_v3/)
|
||||||
|
|
||||||
|
### ⚠️ PS 5.1 血泪教训(2026-08-09 调试 40 分钟才定位)
|
||||||
|
|
||||||
|
1. **PowerShell 变量名不区分大小写!** `$STATE`(路径)会被 `$state`(hashtable)**覆盖** → 状态从未真正持久化 → 超时止损/崩溃检测/已下载清单全部静默失效。**路径变量必须用独特名**(如 `$STATEFILE`)。
|
||||||
|
2. **中文路径下 Add-Content 写新 .json 文件不可靠**(Test-Path 返回 True 但文件实际不存在)→ **输出目录用纯 ASCII,状态用纯文本 .txt**。
|
||||||
|
3. **train_env.json 读取失败必须退出**(曾因 $PROJ 未定义导致配置全空 → TIMEOUT: 0h>budget 差点误删 pod)。已加安全阀:关键字段为空即 exit。
|
||||||
|
4. **脚本必须存 GBK 编码**(PowerShell 5.1 用 ANSI 读),且**不能含非 GBK 字符**(⚠️ \u26a0 等会崩)。
|
||||||
|
5. **Event 日志**(train_events.log)记录关键动作(下载/完成/止损/删pod),未来 session 恢复上下文的依据。
|
||||||
|
6. **⚠️ 完成判定必须用 final 文件(myface_lora.safetensors)作为信号,不能等 e120**!训练在 e110 后直接存 final,**没有 e120 checkpoint**。本次 monitor 用 `hasE120 && hasFinal` 判定 → 永远不触发 → **pod 多烧 12 小时 ~$9**(血泪教训,2026-08-09)。正确:`if ($hasFinal)` 即完成。
|
||||||
|
7. **⚠️ checkpoint 文件名是 6 位零填充 `{0:D6}`**(myface_lora-000010.safetensors),不是 `000$e`(00010)——否则探测全部 404 下载不到。
|
||||||
|
8. **⚠️ 下载循环每轮只下 1 个 + 跳过本地已完整 + aria2c `--continue`**:否则串行下多个 1.18GB 超时导致 persist 来不及执行、每轮重复下载。
|
||||||
|
|
||||||
|
### 需要准备 train_env.json(创建 pod 后填入)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"pod_id": "<创建后从API拿>",
|
||||||
|
"dl_url": "https://<podid>-8888.proxy.runpod.net",
|
||||||
|
"ssh_host": "<publicIp>",
|
||||||
|
"ssh_port": <ssh public port>,
|
||||||
|
"max_hours": 4.0,
|
||||||
|
"total_steps": 3840
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、执行清单(本次训练,按顺序)
|
||||||
|
|
||||||
|
- [ ] 1. 用户确认:**批准启动云端训练**(涉及花钱 $3-4)
|
||||||
|
- [ ] 2. 用 §3.1 API 创建 pod(4090 Secure)
|
||||||
|
- [ ] 3. 轮询拿到 IP/SSH 端口/8888 端口
|
||||||
|
- [ ] 4. **用户 Web Terminal 执行一次** §3.3 的 key 修复命令
|
||||||
|
- [ ] 5. 上传 4 文件 → 解压 → bootstrap(nohup 后台)
|
||||||
|
- [ ] 6. bootstrap 完成 → 启动 train.sh(nohup 后台)
|
||||||
|
- [ ] 7. pod 上启动 http.server 8888 服务 ckpt 目录
|
||||||
|
- [ ] 8. 填好 train_env.json → 注册 Windows 计划任务(每 5 分钟跑 monitor_v3.ps1)
|
||||||
|
- [ ] 9. 用户睡觉。监控全自动:增量下载 checkpoint → 完成/崩溃/超时自动处理
|
||||||
|
- [ ] 10. 早上验证:本地 checkpoints_v3 目录应有一批 563MB 完整 checkpoint
|
||||||
|
- [ ] 11. checkpoint 复制到 WebUI `models\Lora\qwen\` → Rapid-AIO 出图验证
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、关键文件位置
|
||||||
|
|
||||||
|
| 文件 | 路径 |
|
||||||
|
|---|---|
|
||||||
|
| 训练集 | `K:\AI\training\ldf\singled\5. autohandling_v6\train_dataset\` |
|
||||||
|
| 数据集 zip | `cloud/dataset.zip` |
|
||||||
|
| 云端 bootstrap | `cloud/bootstrap.sh` |
|
||||||
|
| 云端训练 | `cloud/train.sh`(已加崩溃自动重启 ×20) |
|
||||||
|
| 正确 SSH key | `cloud/correct_key.pub` |
|
||||||
|
| API key | `projects\脸部LoRA训练-Qwen-Image\.runpod_api_key` |
|
||||||
|
| 监控 v3 | `temp/monitor_v3.ps1` |
|
||||||
|
| 监控配置 | `temp/train_env.json`(待创建 pod 后填) |
|
||||||
|
| 监控日志 | `temp/monitor_v3.log` |
|
||||||
|
| 本地 checkpoint | `temp/checkpoints_v3/` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、铁律(每次训练必须遵守)
|
||||||
|
|
||||||
|
1. **只用 Secure Cloud**,禁 Community(用户明确禁止)
|
||||||
|
2. **创建 pod = 花钱**,必须用户批准后才能执行
|
||||||
|
3. **训练期间零手动干预**,一切自动(用户睡觉)
|
||||||
|
4. **任何新操作先查本文档**,不记得就问,不猜
|
||||||
|
5. **改代码必须升版本号**(页面+终端可见)
|
||||||
|
6. **禁止前台 sleep/轮询**;长期任务后台启动
|
||||||
|
7. **scp 不用中文路径**;下载用 HTTP 代理 + aria2c
|
||||||
|
8. **checkpoint 增量下载**,任何时刻断线已下载的都是安全的
|
||||||
|
9. **余额/时长超预算自动止损**,不裸奔
|
||||||
|
10. **训练完成后自动删 pod**,停计费
|
||||||
|
11. **⚠️ 换 GPU 前先查架构兼容性**:镜像 PyTorch 2.4 支持 CUDA sm_50-90;5090(sm_120) 不可用,4090(sm_89)/A40(sm_86) 可用
|
||||||
|
12. **⚠️ 预缓存验证看 `_qie.safetensors`**(数量=素材数),不是 .npz;确认缓存齐全再启动训练,否则报 "No training items found"
|
||||||
|
13. **文档更新不用问用户**,改完直接写;涉及关键决策(换 GPU/删 pod)才需要确认
|
||||||
|
14. **每次换 pod 都要用户修 SSH key**(RunPod 注册 key 是坏的 I2i//)——除非找到一次性注入正确 key 的方法
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 本次 v4 训练实况(2026-08-10,续炼)
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| pod id | 74oru7saetzjo9(RTX 4090 Secure,0.74/h) |
|
||||||
|
| 类型 | **续炼**(非从头) |
|
||||||
|
| 续炼起点 | myface_lora-000060.safetensors(v3 成果,1.1GB) |
|
||||||
|
| 数据集 | v7(32 图 + 全面重调 caption,3 张白色长手套标注) |
|
||||||
|
| 训练 | dim32/lr1e-4/**50ep**/32张=1600步,实测 ~3.2s/步,~1h15m |
|
||||||
|
| 预缓存 | 云端 bootstrap 内完成(Vae + TextEncoder) |
|
||||||
|
| 输出 | myface_v7(000010-000040 + final,各 1125MB) |
|
||||||
|
| 完成 | final 下载 → 自动删 pod(06:15:35),花费 ~2h ≈ .5 |
|
||||||
|
| 成果位置 | WebUI models\Lora\qwen\ivy\v7\(5 文件,全 1125MB 完整) |
|
||||||
|
|
||||||
|
### v4 新增经验
|
||||||
|
|
||||||
|
1. **续炼流程**:本地打包 v7 dataset.zip → train.sh 加 --network_weights 指旧 checkpoint → 上传旧 checkpoint 到 /workspace/ → bootstrap 里 [3.5] 步骤自动移到 /workspace/ckpt/
|
||||||
|
2. **运行新 pod 后查连接信息**:RunPod API 的
|
||||||
|
untime 字段为空是正常的!连接信息在**顶层**字段(publicIp/portMappings/ports),别等
|
||||||
|
untime 就绪(会误判卡住)
|
||||||
|
3. **SSH key**:本次 env.PUBLIC_KEY 显示正确 key(hmo@daily-workspace),但 SSH 仍 Permission denied → 仍需用户 Web Terminal 执行 §3.3 修复命令(指纹 7Eep5Qz)
|
||||||
|
4. **训练完成无最后中间档**:50ep 训练最终只有 000010-000040 + final(epoch 50 直接存 final,无 000050)——monitor 别等最后中间档,final 出现即完成(与 v3 的 e110 模式一致)
|
||||||
|
5. **monitor 状态残留坑**:旧训练 monitor_state.txt 的 done=True 会让新监控直接跳过 → 每次新训练前必须重置状态文件
|
||||||
|
6. **monitor 变量名坑**:不能用 $env(PS 内置 provider 前缀)当普通变量,会导致状态持久化静默失效
|
||||||
|
7. **本地 run_cache.py 是死代码**:训练走云端,本地预缓存脚本无意义(曾误花时间修它)
|
||||||
|
|
||||||
|
### 成果对比建议
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""预缓存:VAE latent + Text Encoder 输出(冒烟测试数据集)"""
|
||||||
|
import os, subprocess, sys
|
||||||
|
|
||||||
|
os.environ["PYTHONUTF8"] = "1"
|
||||||
|
os.environ["HF_HUB_OFFLINE"] = "1"
|
||||||
|
|
||||||
|
VENV = r"M:\AI\sd\musubi-tuner\.venv\Scripts"
|
||||||
|
PY = os.path.join(VENV, "python.exe")
|
||||||
|
MUSUBI = r"M:\AI\sd\musubi-tuner" # musubi-tuner 根目录(cwd 必须在此,musubi_tuner 包才能导入)
|
||||||
|
SRC = os.path.join(MUSUBI, "src", "musubi_tuner")
|
||||||
|
PROJ = r"D:\F\NewI\opencode\daily-workspace\projects\脸部LoRA训练-Qwen-Image"
|
||||||
|
MODELS = r"M:\AI\sd\models\qwen-edit-2511"
|
||||||
|
|
||||||
|
CFG = r"D:\F\NewI\opencode\daily-workspace\projects\脸部LoRA训练-Qwen-Image\config\dataset_active.toml"
|
||||||
|
VAE = os.path.join(MODELS, "diffusion_pytorch_model.safetensors")
|
||||||
|
TE = os.path.join(MODELS, "text_encoder", "model-00001-of-00004.safetensors")
|
||||||
|
|
||||||
|
steps = [
|
||||||
|
("VAE latent 缓存", [PY, os.path.join(SRC, "qwen_image_cache_latents.py"),
|
||||||
|
"--dataset_config", CFG, "--vae", VAE, "--device", "cuda", "--skip_existing",
|
||||||
|
"--model_version", "edit-2511"]),
|
||||||
|
("TextEncoder 缓存", [PY, os.path.join(SRC, "qwen_image_cache_text_encoder_outputs.py"),
|
||||||
|
"--dataset_config", CFG, "--text_encoder", TE, "--fp8_vl", "--device", "cuda", "--skip_existing",
|
||||||
|
"--model_version", "edit-2511"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
for name, cmd in steps:
|
||||||
|
print(f"\n===== {name} =====")
|
||||||
|
print(" ".join(cmd))
|
||||||
|
# cwd=musubi-tuner 根目录:qwen_image_cache_*.py 内部 from musubi_tuner.dataset import config_utils 才能找到包
|
||||||
|
r = subprocess.run(cmd, cwd=MUSUBI)
|
||||||
|
if r.returncode != 0:
|
||||||
|
print(f"[FAIL] {name} exit={r.returncode}")
|
||||||
|
sys.exit(r.returncode)
|
||||||
|
print(f"[OK] {name}")
|
||||||
|
|
||||||
|
print("\n全部缓存完成")
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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) + 修改图*alpha(alpha 由 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≈1(100% 修改图,修改区内部零残影)
|
||||||
|
- diff < 阈值 → alpha≈0(100% 原图,目标人物/有限修改区零改变)
|
||||||
|
- 修改区膨胀 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.
@@ -0,0 +1,236 @@
|
|||||||
|
# FaceLoRA v3 training monitor (scheduled task, fully autonomous)
|
||||||
|
# ============================================================
|
||||||
|
# 用途:云端训练全自动监控。Windows 计划任务每 5 分钟调用一次。
|
||||||
|
# 功能:
|
||||||
|
# 1. 增量下载 checkpoint(HTTP 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.json:pod_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. 探测云端 checkpoint(HTTP 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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# face_checker 使用说明(素材自动审核 + 智能选图工具)
|
||||||
|
|
||||||
|
## 这是什么
|
||||||
|
|
||||||
|
训练脸部 LoRA 前的**素材质检员 + 选图助手**:
|
||||||
|
- 自动检查每张照片:分辨率、清晰度、人脸数量、人脸大小、正侧脸角度、遮挡、重复图
|
||||||
|
- 红黄绿三档判定:✅合格 / 🟡警告 / ❌不合格,附具体原因
|
||||||
|
- **智能选图**:从一堆照片里自动挑出多样化组合(覆盖特写/半身/全身、不同角度、不同风格),你只需要删掉重复的
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
用哪个 Python:musubi-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 转换
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
@echo off
|
||||||
|
title Face LoRA Console
|
||||||
|
set "PATH=%SystemRoot%\System32;%SystemRoot%;%PATH%"
|
||||||
|
|
||||||
|
set "PY=M:\AI\sd\musubi-tuner\.venv\Scripts\python.exe"
|
||||||
|
set "GUI=%~dp0tools\caption_gui.py"
|
||||||
|
set "URL=http://127.0.0.1:7860"
|
||||||
|
|
||||||
|
echo ============================================
|
||||||
|
echo Face LoRA Console (fusion/material/label/train)
|
||||||
|
echo ============================================
|
||||||
|
|
||||||
|
netstat -ano | findstr ":7860" | findstr "LISTENING" >nul 2>&1
|
||||||
|
if %errorlevel%==0 (
|
||||||
|
echo [INFO] Console already running. Opening browser...
|
||||||
|
start "" %URL%
|
||||||
|
exit /b 0
|
||||||
|
)
|
||||||
|
|
||||||
|
echo [START] Launching console (first start ~1-2 min)...
|
||||||
|
echo [LOG] Look for the "Face LoRA Console" window on screen (keep it open, closing it stops GUI)
|
||||||
|
start "Face LoRA Console" "%PY%" "%GUI%"
|
||||||
|
|
||||||
|
set /a tries=0
|
||||||
|
:wait_loop
|
||||||
|
netstat -ano | findstr ":7860" | findstr "LISTENING" >nul 2>&1
|
||||||
|
if %errorlevel%==0 goto ready
|
||||||
|
set /a tries+=1
|
||||||
|
if %tries% geq 150 goto timeout
|
||||||
|
timeout /t 1 /nobreak >nul
|
||||||
|
goto wait_loop
|
||||||
|
|
||||||
|
:ready
|
||||||
|
echo [OK] Console ready. Opening browser...
|
||||||
|
echo [HINT] The "Face LoRA Console" window = live terminal (real-time logs)
|
||||||
|
start "" %URL%
|
||||||
|
exit /b 0
|
||||||
|
|
||||||
|
:timeout
|
||||||
|
echo [WARN] Startup timeout (150s). Check GPU/RAM or port 7860.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
cd /d "%~dp0"
|
||||||
|
|
||||||
|
echo ============================================
|
||||||
|
echo Face LoRA Training Launcher (overnight)
|
||||||
|
echo ============================================
|
||||||
|
echo.
|
||||||
|
|
||||||
|
REM ---- Pre-flight 1: ComfyUI must be CLOSED (holds ~49GB commit + VRAM)
|
||||||
|
netstat -ano | findstr ":8188" | findstr "LISTENING" >nul 2>&1
|
||||||
|
if not errorlevel 1 (
|
||||||
|
echo [ABORT] ComfyUI is still running ^(port 8188 LISTENING^).
|
||||||
|
echo Training needs the memory and VRAM ComfyUI holds.
|
||||||
|
echo Please close ComfyUI first, then run this script again.
|
||||||
|
echo.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
echo [OK] ComfyUI not running.
|
||||||
|
|
||||||
|
REM ---- Pre-flight 2: need 30GB+ free commit memory
|
||||||
|
powershell -NoProfile -Command "if ((Get-CimInstance Win32_OperatingSystem).FreeVirtualMemory / 1MB -lt 30) { exit 1 }"
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo [ABORT] Less than 30GB free commit memory.
|
||||||
|
echo Close big apps ^(browser / Photoshop^) and retry.
|
||||||
|
echo.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
echo [OK] Memory check passed.
|
||||||
|
|
||||||
|
REM ---- Launch training (only one .py in config dir: the training entry)
|
||||||
|
cd /d "%~dp0config"
|
||||||
|
for %%f in (*.py) do (
|
||||||
|
echo [RUN] %%f
|
||||||
|
"M:\AI\sd\musubi-tuner\.venv\Scripts\python.exe" "%%f"
|
||||||
|
goto :after
|
||||||
|
)
|
||||||
|
:after
|
||||||
|
echo.
|
||||||
|
echo Training process exited. Press any key to close.
|
||||||
|
pause
|
||||||
+111
@@ -0,0 +1,111 @@
|
|||||||
|
# 素材准备指南(给老莫)
|
||||||
|
|
||||||
|
> 训练质量 70% 取决于素材。这步值得认真做,一次做好,训练一次到位。
|
||||||
|
|
||||||
|
## 一、选片标准(15-25 张)
|
||||||
|
|
||||||
|
**总原则:宁缺毋滥,越对越好,不是越多越好。**
|
||||||
|
|
||||||
|
| 要求 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| 数量 | **15-25 张**。少于 10 张易过拟合(只记得训练照),多于 30 张收益递减 |
|
||||||
|
| 分辨率 | 每张 ≥1024×1024。**用手机原图,不要用微信/朋友圈压缩过的图** |
|
||||||
|
| 正脸为主 | 正脸/微侧特写占 8 张左右(决定五官比例、皮肤质感) |
|
||||||
|
| 半身/全身 | 8 张左右(决定身材、服装适配度) |
|
||||||
|
| 侧面/动态/生活 | 4 张左右(决定泛化能力,换姿势也像) |
|
||||||
|
| 光线 | **混合光源**(室内灯、自然光、户外),不要全同一个光线 |
|
||||||
|
| 表情 | 别全是大笑/全是抿嘴,自然表情为主,1-2 张微笑 |
|
||||||
|
| 遮挡 | **不要**戴口罩、墨镜、厚刘海、帽子遮挡五官 |
|
||||||
|
| 背景 | 尽量简单(纯色墙、普通室内),避免复杂背景干扰 |
|
||||||
|
| 年龄 | 照片年龄差距别太大(不要 10 年前的混 3 年前的) |
|
||||||
|
|
||||||
|
**反面教材**:20 张全是同一角度的自拍 → LoRA 只会画那个角度,换个姿势就不像了。
|
||||||
|
|
||||||
|
**选片来源建议**:翻手机相册挑最近 1-2 年的,证件照、日常照、户外照各来几张。
|
||||||
|
|
||||||
|
## 二、打标(每张图配同名 txt)
|
||||||
|
|
||||||
|
**原则:描述场景和动作,不描述脸。脸交给 LoRA 记。**
|
||||||
|
|
||||||
|
- 每张照片旁边放一个**同名 txt 文件**(如 `img_001.jpg` → `img_001.txt`)
|
||||||
|
- 内容:触发词 + 一句场景/姿势/服装描述(英文,ComfyUI 生态惯例)
|
||||||
|
- **禁止写**:五官特征("帅""眼睛大")、具体衣服颜色(会把衣服焊你身上)
|
||||||
|
- **懒人方案**:把照片丢给 AI(豆包/DeepSeek)批量生成描述,5 分钟搞定
|
||||||
|
|
||||||
|
### 触发词(重要)
|
||||||
|
|
||||||
|
- 自己取一个**生僻词**,不要在模型词汇里常见
|
||||||
|
- 建议:`lm_face_v1` 或 `sks_laomo` 这类(lm = 老莫)
|
||||||
|
- 推理时提示词必须带触发词 LoRA 才生效
|
||||||
|
|
||||||
|
### 打标示例
|
||||||
|
|
||||||
|
```
|
||||||
|
# img_001.txt(正脸特写)
|
||||||
|
lm_face_v1, photorealistic portrait, front view, neutral expression, soft indoor lighting, plain background
|
||||||
|
|
||||||
|
# img_002.txt(户外全身)
|
||||||
|
lm_face_v1, full body shot, standing in a park, natural daylight, casual clothing, sharp focus
|
||||||
|
|
||||||
|
# img_003.txt(侧脸生活照)
|
||||||
|
lm_face_v1, three-quarter view, sitting at a cafe table, warm ambient light, candid moment
|
||||||
|
```
|
||||||
|
|
||||||
|
## 三、关键决策规则(老莫必读)
|
||||||
|
|
||||||
|
### 1. 年龄:不要混!以最近 1-2 年为主
|
||||||
|
- 混入多年前的照片 → LoRA 学"平均脸",出图年龄飘忽,两边都不像
|
||||||
|
- 年龄跨度控制在 **±5 年内**;想要"年轻版"效果用提示词(`younger version`),或以后单独训一个年轻版 LoRA
|
||||||
|
|
||||||
|
### 2. 发型:要混!但打标必须写明
|
||||||
|
- 不同发型(寸头/长发/背头/卷发)都选 → LoRA 泛化强,换发型也像
|
||||||
|
- **关键**:每张 caption 必须写发型(如 `short black hair`),否则发型会被"焊死"在脸上,出图换发型就不像
|
||||||
|
|
||||||
|
### 3. 生活照 vs 艺术照:混,8:2 为主
|
||||||
|
- **生活照为主**(提供真实身份特征),**艺术照少量**(3-4 张,提供精致造型参考)
|
||||||
|
- 艺术照**避开**:重度磨皮/美颜(塑料脸)、浓妆(掩盖五官)、棚拍强光死白
|
||||||
|
- 选轻度修饰、五官清晰的艺术照
|
||||||
|
|
||||||
|
### 4. 20 张黄金配方
|
||||||
|
| 类型 | 张数 |
|
||||||
|
|---|---|
|
||||||
|
| 正脸特写 | 8 |
|
||||||
|
| 半身照 | 6 |
|
||||||
|
| 全身照 | 4 |
|
||||||
|
| 侧脸/生活照 | 2 |
|
||||||
|
|
||||||
|
### 5. 角度配比(老莫补充 2026-08-03)
|
||||||
|
| 角度 | 张数(20张) | 作用 |
|
||||||
|
|---|---|---|
|
||||||
|
| 正脸/微侧(±15°) | 12 | 身份核心 |
|
||||||
|
| **45° 半侧脸**(左右各一半) | 6 | 立体结构、泛化关键 |
|
||||||
|
| 纯侧面(90°) | 0-2 | 点缀 |
|
||||||
|
|
||||||
|
- 45° 半侧脸教 LoRA 认识脸的立体结构(颧骨/下颌线/鼻子的三维关系),出图换角度也像
|
||||||
|
- **左右两个方向的 45° 都要有**,别只拍一边
|
||||||
|
- 纯 90° 侧面信息少,最多 1-2 张
|
||||||
|
- **俯拍/仰拍:少量或不要**(会扭曲五官比例:俯拍额头大、仰拍鼻孔放大;AI 出图极少用极端俯仰角)。真要放:每方向最多 1 张、15-20° 轻度即可
|
||||||
|
|
||||||
|
### 6. 排除清单(看到就换)
|
||||||
|
口罩 / 墨镜 / 厚刘海遮挡 / 模糊 / 纯侧面 / 背光死黑 / 过度美颜 / 表情全是一个 / 年龄差距大
|
||||||
|
|
||||||
|
## 四、交付方式
|
||||||
|
|
||||||
|
1. 照片放进 `projects\脸部LoRA训练-Qwen-Image\素材\`(或直接发我,我来整理)
|
||||||
|
2. 我负责:重命名(img_001...)、检查分辨率/质量、批量生成 caption、整理成训练数据集格式
|
||||||
|
3. 你只需要:**选 15-25 张照片**
|
||||||
|
|
||||||
|
## 四、时间预估
|
||||||
|
|
||||||
|
- 选片:10-15 分钟
|
||||||
|
- 打标:AI 批量 5 分钟(我代劳)
|
||||||
|
- 合计:老莫只需花 10 分钟选照片
|
||||||
|
|
||||||
|
## 五、质量自检清单
|
||||||
|
|
||||||
|
- [ ] 15-25 张,全部 ≥1024×1024
|
||||||
|
- [ ] 正脸/微侧为主,角度有变化
|
||||||
|
- [ ] 光线有变化,背景不复杂
|
||||||
|
- [ ] 无遮挡五官(口罩/墨镜/厚刘海)
|
||||||
|
- [ ] 照片都是最近 1-2 年的
|
||||||
|
- [ ] 无重复角度、无模糊照
|
||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
|
||||||
|
set SRC=K:\AI\training\ldf\singled\2. autohandling_test\output\checkpoints
|
||||||
|
set DST=M:\AI\sd\novelai-webui-aki-v3-r\models\Lora\qwen
|
||||||
|
|
||||||
|
echo ============================================
|
||||||
|
echo Deploy LoRA checkpoints to WebUI
|
||||||
|
echo ============================================
|
||||||
|
echo Source: %SRC%
|
||||||
|
echo Target: %DST%
|
||||||
|
echo.
|
||||||
|
|
||||||
|
if not exist "%SRC%" (
|
||||||
|
echo [ABORT] Checkpoint dir not found. Run training first.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
set COUNT=0
|
||||||
|
for %%f in ("%SRC%\myface_lora*.safetensors") do (
|
||||||
|
copy /y "%%f" "%DST%\" >nul
|
||||||
|
echo [COPIED] %%~nxf
|
||||||
|
set /a COUNT+=1
|
||||||
|
)
|
||||||
|
|
||||||
|
if %COUNT%==0 (
|
||||||
|
echo [ABORT] No myface_lora*.safetensors in source dir.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo Done. %COUNT% checkpoint(s) deployed to WebUI.
|
||||||
|
echo.
|
||||||
|
echo === Verification steps in WebUI ===
|
||||||
|
echo 1. Select checkpoint: Qwen-Rapid-AIO-NSFW-v23
|
||||||
|
echo 2. Add LoRA tag to prompt: ^<lora:myface_lora-0000XX:0.8^>
|
||||||
|
echo (XX = epoch number, e.g. 000040 = epoch 40)
|
||||||
|
echo 3. Prompt MUST include trigger word: lm_face_v1
|
||||||
|
echo 4. Compare auto sample images in %SRC%\sample
|
||||||
|
echo to pick the best epoch first.
|
||||||
|
echo.
|
||||||
|
pause
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# 脸部 LoRA 训练项目 · 项目管理总览
|
||||||
|
|
||||||
|
> 最后更新:2026-08-09
|
||||||
|
> 状态:**v3 训练进行中**(4090,约 2.5h 完成)
|
||||||
|
> 唯一入口:本文件 + `docs/云端训练完整操作文档.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 项目目标
|
||||||
|
|
||||||
|
用老婆的照片训练脸部 LoRA,叠加 `Qwen-Rapid-AIO-NSFW-v23` checkpoint 出图。
|
||||||
|
训练底模:Qwen-Image-Edit-2511 bf16 官方底模。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 当前训练状态(实时)
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| GPU | RTX 4090($0.74/h,v2 验证过的最优解) |
|
||||||
|
| pod | `k1nzrqh8lfj7jg`(IP 103.196.86.68:50155) |
|
||||||
|
| 训练 | dim32/lr1e-4/120ep/32张=3840步,~2.5s/步,ETA ~2.5h |
|
||||||
|
| 监控 | 计划任务 `FaceLoRA-Monitor-v3` 每 5 分钟 |
|
||||||
|
| 进度 | **查 `temp/train_env.json` + `C:\Users\hmo\AppData\Local\Temp\opencode\face_lora\`** |
|
||||||
|
| 预算 | 4h 封顶($0.74×4=$2.96),余额 $10 |
|
||||||
|
|
||||||
|
**如何查当前进度(给 AI / 用户的快速指令)**:
|
||||||
|
```powershell
|
||||||
|
# 1. 训练实时进度(SSH pod)
|
||||||
|
ssh -i ~/.ssh/id_rsa -p 50155 root@103.196.86.68 "tail -c 500 /workspace/train.log | tr '\r' '\n' | tail -2"
|
||||||
|
# 2. 监控状态(下载了多少 checkpoint)
|
||||||
|
Get-Content C:\Users\hmo\AppData\Local\Temp\opencode\face_lora\train_events.log
|
||||||
|
Get-ChildItem C:\Users\hmo\AppData\Local\Temp\opencode\face_lora\checkpoints_v3\
|
||||||
|
# 3. pod 是否还在 / 计费
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
脸部LoRA训练-Qwen-Image/
|
||||||
|
├── docs/
|
||||||
|
│ └── 云端训练完整操作文档.md ← 训练全流程唯一权威参考(含全部踩坑)
|
||||||
|
├── tools/
|
||||||
|
│ ├── caption_gui.py ← 打标 GUI(http://127.0.0.1:7860)
|
||||||
|
│ ├── face_checker.py ← 素材审核/选图/auto流水线
|
||||||
|
│ ├── monitor_train_v3.ps1 ← 训练监控(计划任务,全自动)★正式版
|
||||||
|
│ ├── merge_fixed.py ← ComfyUI 融合工具
|
||||||
|
│ └── validate_lora.py ← 训练后验证
|
||||||
|
├── cloud/
|
||||||
|
│ ├── dataset.zip ← 训练集打包(32 img+32 txt)
|
||||||
|
│ ├── bootstrap.sh ← 云端引导(装musubi+下模型+预缓存)
|
||||||
|
│ ├── train.sh ← 云端训练(含崩溃重启)
|
||||||
|
│ ├── correct_key.pub ← 正确 SSH key(指纹 7Eep5Qz)
|
||||||
|
│ └── sample_prompts.txt
|
||||||
|
├── config/
|
||||||
|
│ ├── train_config.json ← GUI 训练配置(train_dataset/output_dir)
|
||||||
|
│ ├── dataset.toml / *_active ← 数据集配置
|
||||||
|
│ └── 训练脚本.py ← 本地训练脚本(已废弃,走云端)
|
||||||
|
├── temp/ ← 运行数据(非正式,可清理)
|
||||||
|
│ ├── train_env.json ← 监控配置(pod_id/dl_url/ssh/预算)★关键
|
||||||
|
│ └── ...
|
||||||
|
├── photos/ 素材/ 打标/ output/ ← 本地素材与输出
|
||||||
|
└── README.md ← 旧版项目说明
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 关键文件索引
|
||||||
|
|
||||||
|
| 文件 | 用途 | 谁改 |
|
||||||
|
|---|---|---|
|
||||||
|
| `docs/云端训练完整操作文档.md` | 训练全流程+踩坑记录 | 训练相关改动必须同步 |
|
||||||
|
| `tools/monitor_train_v3.ps1` | 训练监控(正式版) | 监控逻辑改动 |
|
||||||
|
| `temp/train_env.json` | 当前训练 pod 配置 | 每次新 pod 更新 |
|
||||||
|
| `cloud/train.sh` | 云端训练参数 | 换 GPU/参数时 |
|
||||||
|
| `cloud/bootstrap.sh` | 云端环境搭建 | 环境变化时 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 流程速览
|
||||||
|
|
||||||
|
1. **素材准备**:`photos_原始/` → `face_checker.py auto` → GUI ②候选换图筛选
|
||||||
|
2. **整理训练集**:GUI「📦 整理训练集」→ `train_dataset/`(自动跟随素材目录)
|
||||||
|
3. **打包**:`cloud/dataset.zip`(含 img+txt)
|
||||||
|
4. **云端训练**:创建 pod → 修 SSH key → 上传 → bootstrap → train(详见 docs)
|
||||||
|
5. **监控下载**:计划任务自动增量下载 checkpoint 到 `checkpoints_v3/`
|
||||||
|
6. **验证**:checkpoint → WebUI Rapid-AIO 出图
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ 血泪教训(必须遵守,详见 docs 第八章)
|
||||||
|
|
||||||
|
1. **只用 Secure Cloud**,禁 Community
|
||||||
|
2. **换 GPU 前查架构兼容**:5090(sm_120) 与镜像 PyTorch 不兼容,禁用;4090/A40 可用
|
||||||
|
3. **预缓存验证看 `_qie.safetensors`**(数量=素材数),不是 .npz
|
||||||
|
4. **PS 5.1 变量名不区分大小写**:路径变量别叫 `$STATE`(会被 `$state` 覆盖),用 `$STATEFILE`
|
||||||
|
5. **PS 5.1 脚本存 GBK 编码**,输出目录用纯 ASCII,状态用纯文本(.json 写入不可靠)
|
||||||
|
6. **train_env.json 读取失败必须退出**,绝不用空值删 pod
|
||||||
|
7. **监控全自动**:计划任务每 5 分钟,增量下载 checkpoint,超时/崩溃自动止损,完成自动删 pod
|
||||||
|
8. **文档保持更新**:改了就要同步 docs/(不用问用户)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ ComfyUI workflow 编写要点(2026-08-09 查证,别手写 link id!)
|
||||||
|
|
||||||
|
**血泪教训**:直接改 workflow JSON 加连接(手写 link id 44-47)→ 加载显示正确但实际不稳定(操作其他连线时连接被替换)。用户前端重连(link id 47-50)后稳定。
|
||||||
|
|
||||||
|
**根因(2026-08-09 源码级验证,LiteGraph + ComfyUI 前端 TS 版)**:
|
||||||
|
- workflow UI format 的 link 是 6 元组 `[id, src_node, src_slot, dst_node, dst_slot, type]`
|
||||||
|
- **连接 = 三方引用必须一致**:① links 数组 ② 目标 node 的 `inputs[].link` ③ 源 node 的 `outputs[].links`(数组,一个输出可扇出多条)
|
||||||
|
- 只改 links 数组不动 node 引用 → 线"幽灵"或消失
|
||||||
|
- **核心机制**:连接新线时 `linkId = lastLinkId + 1`;前端加载 workflow 时 `state.lastLinkId = Math.max(当前, JSON值)`,**不扫描 links 数组**。脚本手写 link id 但没同步 lastLinkId → 下次连接新线生成重复 id → `_links.set(id, 新线)` 覆盖手写的连接
|
||||||
|
- **铁律:脚本加 link 后必须把 `state.lastLinkId`(新schema)或 `last_link_id`(旧schema)设为 ≥ 最大 link id**
|
||||||
|
- **正确做法**:连接让前端生成,或脚本严格 lastLinkId 同步 + 三方一致
|
||||||
|
|
||||||
|
**关键规则**:
|
||||||
|
1. API format(执行):`{"id": {"class_type":..., "inputs":{...}}}`,连接 `["源ID字符串", 输出槽]`,**节点 ID 必须字符串**
|
||||||
|
2. UI format(画布):顶层 nodes+links+groups,**保存画布用 UI format**(API→UI 有损丢布局)
|
||||||
|
3. 写节点前查 `/object_info/<NodeType>` 拿真实 schema,别猜 widgets 顺序
|
||||||
|
4. 类型不匹配插转换节点(IMAGE↔LATENT 用 VAEEncode/VAEDecode)
|
||||||
|
5. 验证阶梯:静态检查 → /object_info → 小图 /prompt 冒烟 → 全量
|
||||||
|
6. 参考 skill:`SlavaSexton/ComfyUI-Agent-Kit`(最全)+ `artokun/comfyui-mcp`(精简权威)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 权限与密钥(不提交 Git)
|
||||||
|
|
||||||
|
- RunPod API key:`temp/../.runpod_api_key`(rpa_ 开头,50位)
|
||||||
|
- SSH key:`~/.ssh/id_rsa`(指纹 7Eep5Qz,对应 cloud/correct_key.pub)
|
||||||
|
- Gitea:xiaoxiao 凭证(curl 拉 correct_key.pub 用)
|
||||||
|
- GUI 状态:`tools/gui_state.json`
|
||||||
Reference in New Issue
Block a user