commit 77d0cbd0cb2090a90ffdf55c1b1d5f6ec4c2b40c Author: hmo Date: Mon Aug 10 10:10:34 2026 +0800 脸部LoRA训练项目:素材流水线 + Gradio控制台 + RunPod云端续炼方案(v7续炼完成) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7c390ac --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..67408a0 --- /dev/null +++ b/README.md @@ -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 diff --git a/cloud/bootstrap.sh b/cloud/bootstrap.sh new file mode 100644 index 0000000..ee51306 --- /dev/null +++ b/cloud/bootstrap.sh @@ -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 =====" diff --git a/cloud/correct_key.pub b/cloud/correct_key.pub new file mode 100644 index 0000000..654293e --- /dev/null +++ b/cloud/correct_key.pub @@ -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 \ No newline at end of file diff --git a/cloud/sample_prompts.txt b/cloud/sample_prompts.txt new file mode 100644 index 0000000..70a4a62 --- /dev/null +++ b/cloud/sample_prompts.txt @@ -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 diff --git a/cloud/train.sh b/cloud/train.sh new file mode 100644 index 0000000..89e3e7e --- /dev/null +++ b/cloud/train.sh @@ -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) =====" diff --git a/config/dataset.toml b/config/dataset.toml new file mode 100644 index 0000000..35eb1f3 --- /dev/null +++ b/config/dataset.toml @@ -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 diff --git a/config/dataset_active.toml b/config/dataset_active.toml new file mode 100644 index 0000000..8fe224c --- /dev/null +++ b/config/dataset_active.toml @@ -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 diff --git a/config/dataset_smoke.toml b/config/dataset_smoke.toml new file mode 100644 index 0000000..f490448 --- /dev/null +++ b/config/dataset_smoke.toml @@ -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 \ No newline at end of file diff --git a/config/sample_prompts.txt b/config/sample_prompts.txt new file mode 100644 index 0000000..70a4a62 --- /dev/null +++ b/config/sample_prompts.txt @@ -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 diff --git a/config/train_config.json b/config/train_config.json new file mode 100644 index 0000000..e9ed40b --- /dev/null +++ b/config/train_config.json @@ -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" +} \ No newline at end of file diff --git a/config/训练脚本.ps1 b/config/训练脚本.ps1 new file mode 100644 index 0000000..8e46fbe --- /dev/null +++ b/config/训练脚本.ps1 @@ -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 diff --git a/config/训练脚本.py b/config/训练脚本.py new file mode 100644 index 0000000..87964dd --- /dev/null +++ b/config/训练脚本.py @@ -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) diff --git a/docs/云端训练完整操作文档.md b/docs/云端训练完整操作文档.md new file mode 100644 index 0000000..cff001f --- /dev/null +++ b/docs/云端训练完整操作文档.md @@ -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://-8888.proxy.runpod.net/` +**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 ... 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://-8888.proxy.runpod.net", + "ssh_host": "", + "ssh_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 是死代码**:训练走云端,本地预缓存脚本无意义(曾误花时间修它) + +### 成果对比建议 +- myface_v7.safetensors(final)为主力 +- 与 myface_lora-000100(v3 用户最满意版)对比:重点看**手套**是否正常(本次补了 3 张白色长手套标注) \ No newline at end of file diff --git a/output/run_cache.py b/output/run_cache.py new file mode 100644 index 0000000..1dba46e --- /dev/null +++ b/output/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全部缓存完成") diff --git a/tools/caption_gui.py b/tools/caption_gui.py new file mode 100644 index 0000000..499bcc8 --- /dev/null +++ b/tools/caption_gui.py @@ -0,0 +1,2224 @@ +# -*- coding: utf-8 -*- +""" +脸部 LoRA 一站式控制台(Gradio Web UI) +======================================= +所有功能集中在一个浏览器界面(localhost:7860): + + Tab 1 素材自动处理 : auto 流水线(分类/裁剪/打标草稿/超量筛选/淘汰) + Tab 2 素材审核 : check 红黄绿判定报告 + Tab 3 智能选图 : pick 多样化推荐 + Tab 4 打标 : 照片墙 + 类型模板 + 发型 + 保存 + 整理训练集 + Tab 5 融合工具 : merge_fixed(原图 + ComfyUI 修改图 无缝合并) + Tab 6 训练 : 启动训练 + 样本图浏览 + +用法:python caption_gui.py (浏览器打开 http://127.0.0.1:7860) +""" +import contextlib +import glob +import io +import json +import tempfile +import time +import os +import subprocess +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import face_checker as fc +import merge_fixed as mf +import gradio as gr + +TOOLS_DIR = Path(__file__).resolve().parent +PROJ_DIR = TOOLS_DIR.parent + +# 版本号:每次功能修改后递增,方便确认 GUI 是否已重启生效(界面+终端都会显示) +VERSION = "0.18.45" + +# pythonw(无控制台)兼容:stdout/stderr 为 None 时重定向到 devnull,避免 print 崩溃 +if sys.stdout is None: + sys.stdout = open(os.devnull, "w", encoding="utf-8") +if sys.stderr is None: + sys.stderr = open(os.devnull, "w", encoding="utf-8") + +PHOTOS = PROJ_DIR / "photos" +TRAIN_DATASET = PROJ_DIR / "output" / "train_dataset" +CHECKPOINT_DIR = PROJ_DIR / "output" / "checkpoints" +SAMPLE_DIR = CHECKPOINT_DIR / "sample" +TRIGGER_DEFAULT = "lm_face_v1" +DIR_CFG = TOOLS_DIR / "gui_dir.txt" +STATE_FILE = TOOLS_DIR / "gui_state.json" +# ③打标 caption 快照:随素材目录走({素材目录}/.caption_backup/captions.json), +# 不放在工具目录——复制/移动素材时备份跟随,多个素材目录互不干扰 + + +def clean_path(p): + """清理路径:去掉首尾空白和引号(Ctrl+Shift+C 复制的路径带双引号)。非字符串原样返回""" + if not isinstance(p, str): + return p + return p.strip().strip('"').strip("'").strip() + + +def load_state(): + if STATE_FILE.exists(): + try: + return json.loads(STATE_FILE.read_text(encoding="utf-8")) + except Exception: + return {} + return {} + + +def save_state(key, value): + state = load_state() + # 只有字符串(路径/文本)需要去引号清理;数值/布尔原样保存 + if isinstance(value, str): + value = clean_path(value) + state[key] = value + STATE_FILE.write_text(json.dumps(state, ensure_ascii=False, indent=1), encoding="utf-8") + + +def state_val(key, default): + return load_state().get(key, default) + + +def state_clamp(key, default, lo, hi): + """读取状态并钳制到 [lo, hi],防止旧版本保存的超范围值导致 gradio 报错""" + v = load_state().get(key, default) + try: + v = float(v) + except (TypeError, ValueError): + return default + return min(hi, max(lo, v)) + +STYLES = { + "正脸特写": "photorealistic portrait, front view, neutral expression, soft natural lighting, plain background", + "45度半侧脸": "three-quarter view portrait, head slightly turned, neutral expression, natural lighting, plain background", + "半身照": "half body shot, standing, casual clothing, natural lighting, simple background", + "全身照": "full body shot, standing, casual clothing, outdoor scene, natural lighting", + "生活照": "candid lifestyle photo, natural scene, relaxed pose, natural lighting", + "艺术照": "studio portrait, professional photography, soft lighting, clean background", +} +COLORS = {"合格": "#16a34a", "警告": "#d97706", "不合格": "#dc2626"} + + +def get_photos_dir(): + if DIR_CFG.exists(): + p = Path(DIR_CFG.read_text(encoding="utf-8").strip()) + if p.exists(): + return p + return PHOTOS + + +def thumb_path(photos_dir, fname): + t = photos_dir / ".thumbs" / f"{Path(fname).stem}.jpg" + return str(t) if t.exists() else str(photos_dir / fname) + + +def run_with_log(func, *args, **kwargs): + """执行函数并捕获 stdout 输出,返回 (日志文本, 返回值)""" + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + result = func(*args, **kwargs) + return buf.getvalue(), result + + +# ================= Tab 1: 素材自动处理 ================= +def build_auto_tab(vlm_backend): + with gr.Tab("① 素材自动处理"): + gr.Markdown("**auto 流水线**:丢一堆照片 → 自动质量评分/构图分类/裁剪/打标草稿/超量筛选/淘汰") + with gr.Row(): + in_dir = gr.Textbox(label="输入照片目录", value=state_val("auto_in", str(PHOTOS)), scale=2) + out_dir = gr.Textbox(label="输出目录", value=state_val("auto_out", str(PROJ_DIR / "photos_auto")), scale=2) + with gr.Row(): + trigger = gr.Textbox(label="触发词", value=state_val("auto_trigger", TRIGGER_DEFAULT), scale=1) + limit = gr.Slider(0, 40, value=int(state_clamp("auto_limit", 20, 0, 40)), step=1, label="目标数量(0=不筛选)", scale=1) + with gr.Row(): + use_ollama = gr.Checkbox(value=bool(state_val("auto_use_ollama", False)), + label="启用 VLM 自动描述(使用顶部全局 VLM 后端:omlx=小果30B / ollama=本地8B)", scale=2) + run_btn = gr.Button("🚀 运行 auto 流水线", variant="primary") + stats = gr.HTML("就绪") + log = gr.Textbox(label="处理日志", lines=18, interactive=False, elem_id="auto_log", + autoscroll=True, # 配合 Blocks js 里的 tail-follow:内容更新时自动滚到底(用户上翻则暂停) + elem_classes=["log-tail"]) + + def do_auto(i, o, tr, lim, use_vlm, backend): + """运行 auto 流水线(实时流式输出日志)。返回 generator:每帧 yield (stats_html, 累计日志)""" + import datetime as _dt + from PIL import Image + i, o = clean_path(i), clean_path(o) + save_state("auto_in", i) + save_state("auto_out", o) + save_state("auto_trigger", tr) + save_state("auto_limit", lim) + save_state("auto_use_ollama", use_vlm) + i, o = Path(i), Path(o) + if not i.exists(): + yield '❌ 输入目录不存在', "" + return + person_det = fc.PersonDetector() + det = fc.FaceDetector() + if lim > 0: + n1 = max(1, round(lim * 0.5)) + n2 = max(1, round(lim * 0.3)) + quota = {"特写": n1, "半身": n2, "全身": max(1, lim - n1 - n2)} + else: + quota = None + _t0 = _dt.datetime.now() + ts = _t0.strftime("%Y%m%d_%H%M%S") + # 日志实时落盘 + 流式返回:用可读的 pipe 捕获 stdout,后台线程读 + runlog = PROJ_DIR / "temp" / "auto_runs" + runlog.mkdir(parents=True, exist_ok=True) + logfile = runlog / f"auto_{ts}.log" + header = (f"=== auto 运行 {ts} | GUI v{VERSION} | 后端={backend} | VLM启用={use_vlm} | " + f"开始={_t0.strftime('%H:%M:%S')}\n" + f"input={i} output={o} trigger={tr} limit={lim} " + f"quota={{特写:{quota['特写'] if quota else '不限'}, 半身:{quota['半身'] if quota else '不限'}, " + f"全身:{quota['全身'] if quota else '不限'}}} ===\n") + logfile.write_text(header, encoding="utf-8") + + import threading + result_box = {} + + def _worker(): + import contextlib as _cl + class _LineWriter: + """行缓冲 writer:print 每行立即落盘(redirect_stdout 到文件默认块缓冲,轮询读不到)""" + def __init__(self, path): + self._f = open(path, "a", encoding="utf-8") + def write(self, s): + self._f.write(s) + self._f.flush() + return len(s) + def flush(self): + self._f.flush() + w = _LineWriter(logfile) + try: + with _cl.redirect_stdout(w): + fc.auto_process(str(i), str(o), det, person_det, + trigger=tr, quota=quota, limit=lim > 0, + use_omlx=use_vlm, backend=backend) + result_box["ok"] = True + except Exception as e: + w.write(f"\n[异常] {repr(e)}\n") + result_box["err"] = repr(e) + result_box["ok"] = False + finally: + w.flush() + w._f.close() + + t = threading.Thread(target=_worker, daemon=True) + t.start() + # 主循环:轮询 logfile 尾部,增量 yield(Gradio generator 实时刷新) + _seen = len(header) + while t.is_alive(): + time.sleep(0.4) + txt = logfile.read_text(encoding="utf-8", errors="replace") + if len(txt) > _seen: + _seen = len(txt) + tail = txt[_seen - 400:].strip().splitlines() + last = tail[-1].strip() if tail else "" + yield (f'⏳ 处理中… {last}', txt) + time.sleep(0.3) + txt = logfile.read_text(encoding="utf-8", errors="replace") + _t1 = _dt.datetime.now() + _dur = str(_t1 - _t0).split(".")[0] + # 落盘补全头部(耗时) + try: + with open(logfile, "r", encoding="utf-8") as rf: + c = rf.read() + c = c.replace(f"开始={_t0.strftime('%H:%M:%S')}", + f"开始={_t0.strftime('%H:%M:%S')} 结束={_t1.strftime('%H:%M:%S')} 总耗时={_dur}") + logfile.write_text(c, encoding="utf-8") + except Exception: + pass + if result_box.get("ok"): + # 参数链式传递:输出目录自动成为 ② 换图 的默认目录 + DIR_CFG.write_text(str(o), encoding="utf-8") + yield (f'✅ 完成,输出到 {o}(后端 {backend},总耗时 {_dur})' + f'
➡️ 下一步:到 ② 候选换图 调整素材 + 审改 caption(目录已自动填好,F5 刷新生效)', txt) + else: + yield f'❌ auto 运行失败: {result_box.get("err", "未知错误")}', txt + + run_btn.click(do_auto, [in_dir, out_dir, trigger, limit, use_ollama, vlm_backend], [stats, log]) + + +# ================= Tab 2: 素材审核 ================= +def build_check_tab(): + with gr.Tab("🔍 审核(可选)"): + gr.Markdown("**check(可选工具,非主线)**:红黄绿判定(分辨率/清晰度/人脸/遮挡/重复),生成 HTML 报告。" + "主线流程 ② auto 已内置质量审核,一般不需要单独用。") + with gr.Row(): + c_dir = gr.Textbox(label="照片目录", value=state_val("check_dir", str(PHOTOS)), scale=3) + c_btn = gr.Button("🔍 审核", variant="primary") + c_stats = gr.HTML("就绪") + c_log = gr.Textbox(label="结果", lines=15, interactive=False) + + def do_check(d): + d = clean_path(d) + save_state("check_dir", d) + d = Path(d) + if not d.exists(): + return '❌ 目录不存在', "" + log_txt, results = run_with_log(fc.analyze_images, str(d), fc.FaceDetector(), verbose=False) + counts = {s: sum(1 for r in results if r["status"] == s) for s in fc.STATUS.values()} + report = Path(d) / "素材审核报告.html" + fc.build_html(results, report) + summary = (f"✅ 合格 {counts['合格']} · 🟡 警告 {counts['警告']} · ❌ 不合格 {counts['不合格']} · " + f"共 {len(results)} 张
报告: 打开审核报告") + detail = "\n".join(f"[{r['status']}] {r['file']} | {r['reasons'][1]} | {r['reasons'][2][:60]}" + for r in results[:30]) + return f'{summary}', detail + + c_btn.click(do_check, [c_dir], [c_stats, c_log]) + + +# ================= Tab 3: 智能选图 ================= +def build_pick_tab(): + with gr.Tab("🎯 选图(可选)"): + gr.Markdown("**pick(可选工具,非主线)**:从大量照片自动挑出多样化组合。" + "主线流程 ② auto 已内置智能筛选(角度保底+去重),一般不需要单独用。") + with gr.Row(): + p_dir = gr.Textbox(label="照片目录", value=state_val("pick_dir", str(PHOTOS)), scale=3) + p_count = gr.Slider(5, 40, value=int(state_clamp("pick_count", 20, 5, 40)), step=1, label="选多少张", scale=1) + p_btn = gr.Button("🎯 智能选图", variant="primary") + p_stats = gr.HTML("就绪") + p_log = gr.Textbox(label="推荐清单", lines=15, interactive=False) + + def do_pick(d, n): + d = clean_path(d) + save_state("pick_dir", d) + save_state("pick_count", n) + d = Path(d) + if not d.exists(): + return '❌ 目录不存在', "" + log_txt, picked = run_with_log(fc.pick_diverse, fc.analyze_images(str(d), fc.FaceDetector(), verbose=False), n) + detail = "\n".join(f"[{r.get('compose','')}] {r['file']} ({r['status']})" for r in picked) + return (f'✅ 推荐 {len(picked)} 张(目标 {n})', detail) + + p_btn.click(do_pick, [p_dir, p_count], [p_stats, p_log]) + + +# ================= Tab 4: 打标 ================= +def gen_caption(trigger, style, hair, custom): + if custom.strip(): + return custom.strip() + parts = [trigger.strip() or TRIGGER_DEFAULT] + if hair.strip(): + parts.append(hair.strip()) + parts.append(STYLES[style]) + return ", ".join(parts) + + +def _collect_label_images(base): + """收集打标目录的图片:优先按 auto 输出的 特写/半身/全身 子目录(有序分组),否则按扁平目录。 + 返回 [(img_path, kind)],kind 用于分组标签。不再跑人脸检测(素材已过质量关),启动秒开。""" + base = Path(base) + imgs = [] + subdirs = [k for k in ("特写", "半身", "全身") if (base / k).is_dir()] + if subdirs: + for kind in subdirs: + for p in sorted((base / kind).glob("*.jpg")): + if ".bak" in p.name or p.name.startswith("换出_"): + continue + imgs.append((p, kind)) + else: + for p in sorted(base.iterdir()): + if p.suffix.lower() in {".jpg", ".jpeg", ".png", ".webp"} and p.is_file(): + imgs.append((p, "照片")) + return imgs + + +def build_label_tab(demo, vlm_backend): + with gr.Tab("③ 打标"): + gr.Markdown("**③ 打标**:输入目录 → 点「🔄 加载此目录」**立即重载**(不用 F5)→ 审改 caption " + "(单图可点「🔄 重新生成 caption」,用页面顶部全局 VLM 后端)" + "→ 💾 保存 → 📦 一键整理训练集 → ➡️ 去 **④ 训练**") + with gr.Row(): + dir_tb = gr.Textbox(value=str(get_photos_dir()), label="打标目录(auto 输出目录或扁平目录)", scale=3) + dir_btn = gr.Button("🔄 加载此目录", variant="primary", scale=1) + + @gr.render(inputs=dir_tb, triggers=[dir_btn.click, demo.load]) + def render_cards(dir_value): + dir_value = clean_path(dir_value) + p = Path(dir_value) if dir_value else None + if not p or not p.exists(): + gr.HTML('❌ 目录不存在,请检查路径') + return + DIR_CFG.write_text(str(p), encoding="utf-8") + images = _collect_label_images(p) + n_backup = _backup_label_captions(p) + status_html = gr.HTML( + f'已加载 {len(images)} 张({p})· 已备份 {n_backup} 个 caption' + f'' + f'(误改可用「♻️ 重置所有修改」回退到加载时状态)') + # 打标关键字统计(按类别,含水平/垂直角度) + kw_html = gr.HTML(_keyword_stats_selected(p), elem_id="label_kw_stats") + # 拖拽批量替换的通道(CSS 隐藏但保留 DOM——Gradio5 render 下 visible=False 不渲染 DOM,JS 找不到) + kw_old_tb = gr.Textbox(visible=True, elem_id="kw_old_tb", label="") + kw_new_tb = gr.Textbox(visible=True, elem_id="kw_new_tb", label="") + kw_apply_btn = gr.Button(visible=True, elem_id="kw_apply_btn") + # 双击词条的编辑面板(比 prompt 舒服:内联编辑 + 空格拆分) + with gr.Row(): + kw_edit_tb = gr.Textbox( + label="✏️ 编辑词条(双击统计项自动填入;多个 tag 用逗号分隔,中英文逗号均可 → 自动拆分)", + placeholder="例如:黑蕾丝抹胸裙, 粉百褶裙, 白手套, 粉高跟鞋", scale=3, + elem_id="kw_edit_tb") + kw_edit_btn = gr.Button("✅ 应用修改", variant="primary", scale=1) + kw_del_btn = gr.Button("🗑️ 删除此词条", variant="stop", scale=1) + kw_edit_status = gr.HTML('') + # 拥有该词条的图缩略图集合(双击词条后显示,确认影响范围) + # 用 HTML 自绘网格:固定小尺寸缩略图 + contain 完整显示 + flex 换行(Gradio Gallery 布局不可控) + kw_gallery = gr.HTML( + '
' + '🖼️ 拥有该词条的图(双击词条后自动显示)
', + elem_id="kw_gallery") + with gr.Row(): + opt_btn = gr.Button("🧠 一键 LLM 整理全部 Caption(拆短句+合并同义词)", variant="primary", scale=2) + save_btn = gr.Button("💾 保存所有 Caption", variant="primary", scale=1) + reset_btn = gr.Button("♻️ 重置所有修改(回退到加载时备份)", scale=1) + prepare_btn = gr.Button("📦 一键整理训练集 → 训练目录", scale=1) + cards = [] + for ci, (img_path, kind) in enumerate(images): + txt_path = img_path.with_suffix(".txt") + existing = txt_path.read_text(encoding="utf-8").strip() if txt_path.exists() else "" + # elem_id 供缩略图点击跳转定位(img_card_{ci} / cap_tb_{ci}) + with gr.Group(elem_id=f"img_card_{ci}"): + with gr.Row(): + with gr.Column(scale=1, min_width=180): + gr.Image(value=str(img_path), type="filepath", height=170, container=False) + with gr.Column(scale=2): + gr.HTML(f"
{kind}/{img_path.name}
") + cap_tb = gr.Textbox(label="Caption(可直接修改)", lines=3, value=existing, + elem_id=f"cap_tb_{ci}") + regen_btn = gr.Button("🔄 重新生成 caption(用顶部所选 VLM)", size="sm") + cards.append((img_path, cap_tb)) + + def _make_regen(img_path=img_path, kind=kind, cap_tb=cap_tb): + def regen(backend): + """单图重新生成 caption(统一走 fc.vlm_caption)。失败不覆盖原 caption。""" + try: + caption, angle_en = fc.vlm_caption(str(img_path), kind, backend=backend, + trigger=TRIGGER_DEFAULT) + if not caption: + return gr.update(), f'⚠️ {img_path.name}: VLM 角度判定失败({backend} 未响应?),caption 未变' + return caption, f'✅ {img_path.name} 已重新生成({backend},{angle_en}),确认后点 💾 保存' + except Exception as e: + return gr.update(), f'❌ {img_path.name} 重新生成失败: {e}(caption 未变)' + return regen + + def _make_blur_save(img_path=img_path, cap_tb=cap_tb): + def blur_save(cap): + """Caption 框失焦 → 自动写 txt(编辑即固化文件,统计实时反映),不更新备份。 + 用户说:直接改 Caption 框里的文字,结束编辑时自动保存到 txt 并刷新统计。""" + try: + img_path.with_suffix(".txt").write_text((cap or "").strip(), encoding="utf-8") + return (f'💾 {img_path.name} caption 已自动保存', + _keyword_stats_selected(p)) + except Exception as e: + return f'❌ {img_path.name} 自动保存失败: {e}', _keyword_stats_selected(p) + return blur_save + + cap_tb.blur(_make_blur_save(), inputs=cap_tb, outputs=[status_html, kw_html]) + regen_btn.click(_make_regen(), inputs=vlm_backend, outputs=[cap_tb, status_html]) + + def opt_all(*caps): + """一键 LLM 整理所有 caption(批量一起提交,合并同义词/同类项)。 + 返回每个 cap_tb 的新值 + 状态。""" + cur = [c or "" for c in caps] + # 有内容的才提交 + valid = [c for c in cur if c.strip()] + if not valid: + return (*[gr.update() for _ in cur], + '⚠️ 没有可整理的 caption(先加载目录/输入 caption)') + status = (f'⏳ 正在用 LLM 整理 {len(valid)} 个 caption(合并同义词/拆短句)…') + # 先显示"处理中"(Gradio 同步调用会阻塞,简化:直接调,完成后更新) + mapping = _llm_optimize_captions(valid, backend=state_val("vlm_backend", "omlx-32b")) + outs = [] + if not mapping: + for c in cur: + outs.append(gr.update()) + return (*outs, + f'❌ LLM 整理失败(后端未响应?)——请检查 VLM 后端,或手动编辑') + changed = 0 + for c in cur: + # 空串保护:LLM 返回空时保留原值,不覆盖成空 caption + if c in mapping and mapping[c] and mapping[c] != c: + outs.append(mapping[c]) + changed += 1 + else: + outs.append(c) + msg = (f'✅ 已整理 {len(mapping)} 个 caption({changed} 个有变化)——' + f'结果已填入下方,确认后点 💾 保存(统计实时更新)') + return (*outs, msg, _keyword_stats_from_texts(outs)) + + def save_all(*caps): + n = 0 + for (img_path, _), cap in zip(cards, caps): + img_path.with_suffix(".txt").write_text(cap.strip(), encoding="utf-8") + n += 1 + # 落盘后同步更新备份:备份=最近保存状态,「重置」只撤销未保存的修改,不会抹掉已保存内容 + _backup_label_captions(p) + msg = (f'✅ 已保存 {n} 个 caption(原地保存到各图片旁)' + f'
➡️ 下一步:点「📦 一键整理训练集」') + # 统计口径与加载一致:从磁盘全量读 + return msg, _keyword_stats_selected(p) + + def prepare(): + train_ds = TRAIN_DATASET + cfg = _train_cfg() + if cfg.get("train_dataset"): + train_ds = Path(cfg["train_dataset"]) + train_ds.mkdir(parents=True, exist_ok=True) + for f in train_ds.glob("*"): + if f.is_file(): + f.unlink() + copied, skipped = 0, [] + from PIL import Image as _Img + for i, (img_path, cap_tb) in enumerate(cards, 1): + txt_path = img_path.with_suffix(".txt") + if not txt_path.exists(): + skipped.append(img_path.name) + continue + stem = f"img_{i:03d}" + _Img.open(img_path).convert("RGB").save(train_ds / f"{stem}.jpg", quality=95) + (train_ds / f"{stem}.txt").write_text(txt_path.read_text(encoding="utf-8").strip(), encoding="utf-8") + copied += 1 + # 写回 train_config.json:保证 ④ 训练的目录默认值与此处一致(单一事实来源) + cfg["train_dataset"] = str(train_ds) + TRAIN_CONFIG.write_text(json.dumps(cfg, ensure_ascii=False, indent=1), encoding="utf-8") + msg = (f'✅ 已整理 {copied} 张到 {train_ds}' + f'
➡️ 下一步:到 ④ 训练 Tab(目录已自动同步),先点「🔄 重新预缓存」,再启动训练') + if skipped: + msg += f'
⚠️ {len(skipped)} 张缺 caption({", ".join(skipped[:5])}),先保存再整理' + return msg + + opt_btn.click(opt_all, inputs=[c[1] for c in cards], outputs=[c[1] for c in cards] + [status_html, kw_html]) + save_btn.click(save_all, inputs=[c[1] for c in cards], outputs=[status_html, kw_html]) + + def apply_kw_replace(old_word, new_word, *caps): + """批量替换:把所有 caption 中的 old_word 替换成 new_word(写 txt 供统计,不更新备份)。 + 拖拽/双击触发的统一入口(旧文本 → 新文本)。确认后点 💾 保存才固化到备份锚点。""" + old_word = (old_word or "").strip() + new_word = (new_word or "").strip() + if not old_word or not new_word or old_word == new_word: + return (*caps, _keyword_stats_selected(p), + '⚠️ 未执行:源/目标文本不能为空或相同') + n = 0 + new_caps = [] + for (img_path, _), cap in zip(cards, caps): + if _kw_has(cap, old_word): # 词条级:只替换独立词条,不误伤「黑色长发」 + cap = _kw_replace_all(cap, old_word, new_word) + # 编辑即写 txt(统计依赖文件),但不更新备份——只有 💾 保存才更新备份锚点 + img_path.with_suffix(".txt").write_text(cap.strip(), encoding="utf-8") + n += 1 + new_caps.append(cap) + msg = (f'✅ 已替换 {n} 个 caption:' + f'「{old_word}」→「{new_word}」(词条级,已写入 txt;点 💾 保存固化备份锚点)') + # 统计口径与加载一致:只统计选中图(与界面卡片同一口径) + return (*new_caps, _keyword_stats_selected(p), msg) + + kw_apply_btn.click(apply_kw_replace, + inputs=[kw_old_tb, kw_new_tb] + [c[1] for c in cards], + outputs=[c[1] for c in cards] + [kw_html, status_html]) + + def apply_kw_edit(old_word, new_raw, *caps): + """编辑面板:把 caption 中的 old_word 替换成编辑后的内容。 + 新文本用空格分隔多个词时 → 自动拆成多个 tag(, 连接),写回 txt,刷新统计。""" + import re as _re + old_word = (old_word or "").strip() + new_raw = (new_raw or "").strip() + if not old_word or not new_raw: + return (*[gr.update() for _ in caps], _keyword_stats_selected(p), + '⚠️ 未执行:词条不能为空') + # 逗号分隔(中英文均可)→ 拆成多个 tag;单 tag 原样(前后空白清掉) + # 不用空格分隔:英文词条(Hello Kitty / front view)本身含空格,空格有歧义 + _tags = [x.strip() for x in _re.split(r"[,,]", new_raw) if x.strip()] + new_word = ", ".join(_tags) + n = 0 + new_caps = [] + for (img_path, _), cap in zip(cards, caps): + if _kw_has(cap, old_word): # 词条级:只替换独立词条,不误伤「黑色长发」 + cap = _kw_replace_all(cap, old_word, new_word) + img_path.with_suffix(".txt").write_text(cap.strip(), encoding="utf-8") + n += 1 + new_caps.append(cap) + msg = (f'✅ 已应用:{old_word}{new_word}({n} 个 caption)' + f'
空格已拆分多个 tag;已写 txt,可继续编辑或 💾 保存固化') + return (*new_caps, _keyword_stats_selected(p), msg) + + kw_edit_btn.click(apply_kw_edit, + inputs=[kw_old_tb, kw_edit_tb] + [c[1] for c in cards], + outputs=[c[1] for c in cards] + [kw_html, kw_edit_status]) + + def delete_kw_word(old_word, *caps): + """删除词条:从所有 caption 中移除该词条(写 txt,不更新备份——保存前可重置恢复)。 + 支持:独立段直接删 / +或、连接段内删 / 段删空后清理逗号。""" + import re as _re + old_word = (old_word or "").strip() + if not old_word: + return (*[gr.update() for _ in caps], _keyword_stats_selected(p), '', + '⚠️ 未执行:先双击统计里的词条选择要删除的词') + n, new_caps = 0, [] + for (img_path, _), cap in zip(cards, caps): + if not _kw_has(cap, old_word): # 词条级判断(避免「长发」误删「黑色长发」) + new_caps.append(cap) + continue + # 按逗号分段,段内支持 + 、 空格 分隔 + kept_segs = [] + for seg in cap.split(","): + seg = seg.strip() + if not seg: + continue + # 段内拆分子项(+、/、、 都是并列分隔) + parts = _re.split(r"[+/、]", seg) + parts = [p.strip() for p in parts if p.strip()] + # 删除完全匹配的词条(子项级) + parts = [p for p in parts if p != old_word] + if not parts: + continue # 该段被删空 + # 段内保留子项按原分隔符重组 + if "+" in seg: + kept_segs.append("+".join(parts)) + elif "、" in seg: + kept_segs.append("、".join(parts)) + else: + kept_segs.append(" ".join(parts) if len(parts) > 1 else parts[0]) + new_cap = ", ".join(kept_segs) + # 清理多余逗号/空格(如 "a,, b" → "a, b") + new_cap = _re.sub(r",\s*,", ",", new_cap).strip(" ,") + if new_cap != cap: + img_path.with_suffix(".txt").write_text(new_cap.strip(), encoding="utf-8") + n += 1 + new_caps.append(new_cap) + msg = (f'✅ 已删除词条 {old_word}({n} 个 caption 受影响)' + f'
已写 txt;可继续编辑或 💾 保存固化(保存前可 ♻️ 重置恢复)') + return (*new_caps, _keyword_stats_selected(p), '', msg) + + kw_del_btn.click(delete_kw_word, + inputs=[kw_old_tb] + [c[1] for c in cards], + outputs=[c[1] for c in cards] + [kw_html, kw_edit_tb, kw_edit_status]) + + def show_kw_images(old_word): + """双击词条(kw_old_tb 被 JS 填入)→ 找出 caption 含该词的选中图,渲染缩略图网格。 + 固定小缩略图(宽96/高128)+ contain 完整显示 + flex 换行,不裁剪不滚动。""" + import html as _html + old_word = (old_word or "").strip() + if not old_word: + return ('
' + '🖼️ 拥有该词条的图(双击词条后自动显示)
') + hits = [] # (图路径, 卡片索引) + for ci, (img_path, _kind) in enumerate(cards): + txt = img_path.with_suffix(".txt") + try: + cap_txt = txt.read_text(encoding="utf-8") if txt.exists() else "" + # 词条级:只显示独立词条 == old_word 的图(「长发」不含「黑色长发」的图) + if _kw_has(cap_txt, old_word): + hits.append((str(img_path), ci)) + except Exception: + continue + if not hits: + return (f'
' + f'🖼️ 没有图含「{_html.escape(old_word)}」
') + items = [] + for p, ci in hits: + items.append( + f'
' + f'' + f'
' + f'{_html.escape(Path(p).name)}
') + inner = "".join(items) + return (f'
' + f'🖼️ 拥有「{_html.escape(old_word)}」的图({len(hits)} 张)
' + f'
{inner}
') + + # kw_old_tb 被 JS(双击/拖拽)填值后 change → 刷新缩略图集合 + kw_old_tb.change(show_kw_images, inputs=kw_old_tb, outputs=kw_gallery) + + def reset_all(*caps): + """用最近落盘状态(加载/保存/替换时的快照)恢复所有 caption(写文件 + 刷新文本框 + 刷新统计)。 + 只撤销未保存的修改,已保存的内容不会丢。备份缺失的保持当前值不变。""" + bp = _label_backup_path(p) + if not bp.exists(): + return (*[gr.update() for _ in caps], _keyword_stats_selected(p), + '⚠️ 未找到备份(请先重新加载目录)') + try: + snap = json.loads(bp.read_text(encoding="utf-8")) + except Exception as e: + return (*[gr.update() for _ in caps], _keyword_stats_selected(p), + f'❌ 备份读取失败: {e}') + new_caps, restored = [], 0 + for (img_path, _), cur in zip(cards, caps): + key = str(img_path.with_suffix(".txt")) + if key in snap: + new_caps.append(snap[key]) + img_path.with_suffix(".txt").write_text(snap[key], encoding="utf-8") + restored += 1 + else: + new_caps.append(cur) + msg = (f'✅ 已恢复到最近保存/加载状态:{restored} 个 caption(已写文件)' + f'
提示:未保存的修改已撤销;再次「🔄 加载此目录」或「💾 保存」会更新备份锚点') + # 统计口径与加载一致:只统计选中图(与界面卡片同一口径) + return (*new_caps, _keyword_stats_selected(p), msg) + + reset_btn.click(reset_all, inputs=[c[1] for c in cards], + outputs=[c[1] for c in cards] + [kw_html, status_html]) + prepare_btn.click(prepare, outputs=status_html) + + +# ================= Tab: 候选换图 ================= +_SWAP_DET = [None] + + +def _swap_detector(): + """FaceDetector 懒加载单例(YuNet 初始化耗时,避免每次新建)""" + if _SWAP_DET[0] is None: + _SWAP_DET[0] = fc.FaceDetector() + return _SWAP_DET[0] + + +_ANGLE_H_OPTS = ["front view", "three-quarter view", "side view"] +_ANGLE_V_OPTS = ["平视", "high angle view", "low angle view"] +_EXPR_OPTS = ["微笑", "露齿笑", "大笑", "中性", "严肃", "惊讶", "其他"] +_COMP_OPTS = ["特写", "半身", "全身"] +_COMP_TEMPLATE = {"特写": "photorealistic portrait", "半身": "half body shot", "全身": "full body shot"} +_ANGLE_ZH = {"front view": "正面", "three-quarter view": "前侧", "side view": "侧面", + "high angle view": "俯拍", "low angle view": "仰拍"} + + +def _parse_caption(cap): + """从 caption 解析结构化字段。返回 dict(h, v, comp, expr, desc_tail)""" + cap = cap or "" + h, v, comp, expr, tail = "front view", "平视", "特写", "", "" + # 角度:提取 水平/垂直 角度词 + angs = [a for a in _ANGLE_H_OPTS + ["high angle view", "low angle view"] if a in cap] + h = angs[0] if angs else "front view" + v = "平视" + for a in angs: + if a in ("high angle view", "low angle view"): + v = a + h = next((x for x in angs if x in _ANGLE_H_OPTS), "front view") + # 构图模板 + for c, tpl in _COMP_TEMPLATE.items(): + if tpl in cap: + comp = c + break + # desc:模板之后的部分 + parts = [p.strip() for p in cap.split(",")] + # parts[0]=trigger, parts[1]=角度+模板(可能含逗号), parts[2:]=desc + # 找 desc 起点:跳过 trigger 和 角度/模板部分 + idx = 0 + for i, p in enumerate(parts): + if i > 0 and not any(a in p for a in _ANGLE_H_OPTS + ["high angle view", "low angle view"]) \ + and not any(t in p for t in _COMP_TEMPLATE.values()): + idx = i + break + desc_parts = parts[idx:] + tail = ", ".join(desc_parts) if desc_parts else "" + # 表情:从段里提取「标准表情词」本身(不是整段——整段可能是"微笑,淡妆口红"等长句,喂给下拉框会报 + # "Value is not in the list of choices");匹配不到给默认"微笑"(下拉框 choices 必须命中) + expr = "微笑" + if desc_parts: + for dp in desc_parts: + hit = next((k for k in _EXPR_OPTS if k in dp), None) + if hit: + expr = hit + break + return {"h": h, "v": v, "comp": comp, "expr": expr, "tail": tail} + + +def _build_caption(h, v, comp, expr, tail, trigger="lm_face_v1"): + """按结构化字段重建 caption(保留 desc 尾部;表情用关键词替换定位)""" + angle = h if v == "平视" else f"{h}, {v}" + tpl = _COMP_TEMPLATE.get(comp, "photorealistic portrait") + if not tail: + return f"{trigger}, {angle} {tpl}" + tparts = [p.strip() for p in tail.split(",")] + # 替换表情元素(首个含表情关键词的段) + replaced = False + for i, tp in enumerate(tparts): + if any(k in tp for k in _EXPR_OPTS): + tparts[i] = expr + replaced = True + break + if not replaced: + if comp == "特写": + tparts.insert(0, expr) + else: + tparts.insert(1, expr) # natural lighting 之后 + return f"{trigger}, {angle} {tpl}, {', '.join(tparts)}" + + +def _stats_summary(base_dir): + """统计已选素材的 水平角度/垂直角度/表情/构图 分布(不含重复的"已选N张"——status 行已有)""" + from collections import Counter + selected, _ = _swap_collect(base_dir) + h_c, v_c, e_c, comp_c = Counter(), Counter(), Counter(), Counter() + for p, _lbl in selected: + txt = Path(p).with_suffix(".txt") + cap = txt.read_text(encoding="utf-8").strip() if txt.exists() else "" + d = _parse_caption(cap) + h_c[_ANGLE_ZH.get(d["h"], d["h"])] += 1 + v_c[d["v"]] += 1 + e_c[d["expr"] or "?"] += 1 + comp_c[d["comp"]] += 1 + def fmt(c): + return " ".join(f"{k}:{n}" for k, n in c.most_common()) + return (f"
" + f"水平: {fmt(h_c)}
垂直: {fmt(v_c)}
表情: {fmt(e_c)}
构图: {fmt(comp_c)}
") + + +def _stats_advice(base_dir, total=None): + """总数建议(唯一保留在下方的大提示):总数 过高/偏少""" + if total is None: + selected, _ = _swap_collect(base_dir) + total = len(selected) + SUGGEST_MAX_TOTAL = 30 + if total > SUGGEST_MAX_TOTAL: + t = (f"图片总数 {total},高于建议上限 {SUGGEST_MAX_TOTAL}" + f"(建议精简到 ≤{SUGGEST_MAX_TOTAL},否则训练时间长且易过拟合)") + return (f'
⚠️ {t}
') + if total < 15: + t = f"图片总数 {total},偏少(建议 ≥15,尤其补 大笑/俯拍/仰拍 等稀缺素材)" + return (f'
⚠️ {t}
') + return '✅ 素材分布健康,可整理训练集' + + +def _field_warn_html(warns): + """字段级紧凑警告:[(名称, 数量, 界限, 不够/过多)] → 行内警告 HTML(空则返回 "")""" + parts = [] + for k, n, bound, kind in warns: + if kind == "不够" and n < bound: + parts.append(f"⚠️{k} {n} 不够(≥{bound})") + elif kind == "过多" and n > bound: + parts.append(f"⚠️{k} {n} 过多(≤{bound})") + if not parts: + return "" + return f'{";".join(parts)}' + + +def _stats_fields(base_dir): + """返回 4 个字段的分布小字 HTML(各字段后带自己的 不够/过多 警告,同一行)+ 总数建议""" + from collections import Counter + selected, _ = _swap_collect(base_dir) + total = len(selected) + h_c, v_c, e_c, comp_c = Counter(), Counter(), Counter(), Counter() + for p, _lbl in selected: + txt = Path(p).with_suffix(".txt") + cap = txt.read_text(encoding="utf-8").strip() if txt.exists() else "" + d = _parse_caption(cap) + h_c[_ANGLE_ZH.get(d["h"], d["h"])] += 1 + v_c[d["v"]] += 1 + e_c[d["expr"] or "?"] += 1 + comp_c[d["comp"]] += 1 + def fmt(c, keys): + # 标签:亮灰小字;数字:亮色加粗稍大(适配深色背景) + return ' '.join( + f'{k}:' + f'{c.get(k,0)}' + for k in keys) + S = '' + E = '' + h_w = _field_warn_html([("正面", h_c.get("正面", 0), 18, "过多"), + ("前侧", h_c.get("前侧", 0), 2, "不够"), + ("侧面", h_c.get("侧面", 0), 1, "不够")]) + v_w = _field_warn_html([("俯拍", v_c.get("high angle view", 0), 1, "不够"), + ("仰拍", v_c.get("low angle view", 0), 1, "不够")]) + e_w = _field_warn_html([("大笑", e_c.get("大笑", 0), 1, "不够"), + ("露齿笑", e_c.get("露齿笑", 0), 2, "不够"), + ("严肃", e_c.get("严肃", 0), 1, "不够"), + ("惊讶", e_c.get("惊讶", 0), 1, "不够")]) + comp_w = _field_warn_html([("特写", comp_c.get("特写", 0), 20, "过多"), + ("半身", comp_c.get("半身", 0), 3, "不够"), + ("全身", comp_c.get("全身", 0), 1, "不够")]) + return (f"{S}{fmt(h_c,['正面','前侧','侧面'])}{E}{h_w}", + f"{S}{fmt(v_c,['平视','high angle view','low angle view'])}{E}{v_w}", + f"{S}{fmt(e_c,['微笑','露齿笑','大笑','中性','严肃','惊讶'])}{E}{e_w}", + f"{S}{fmt(comp_c,['特写','半身','全身'])}{E}{comp_w}", + _stats_advice(base_dir, total)) + + +def _swap_collect(base_dir): + """收集 auto 输出目录的 已选素材(特写/半身/全身)+ 未选中候选""" + base = Path(base_dir) + selected, cands = [], [] + for kind in ("特写", "半身", "全身"): + d = base / kind + if d.exists(): + for p in sorted(d.glob("*.jpg")): + if ".bak" in p.name or p.name.startswith("换出_"): + continue + selected.append((str(p), f"{kind}/{p.name}")) + ud = base / "未选中" + if ud.exists(): + for p in sorted(ud.iterdir()): + if p.suffix.lower() in {".jpg", ".jpeg", ".png", ".webp"} and p.is_file(): + cands.append((str(p), p.name)) + return selected, cands + + +def _bust(items): + """显示层 cache-bust:把图复制到带时间戳的临时目录,路径每次不同 → 浏览器强制重拉。 + 操作层(sp/cp State)仍用真实路径,此处只影响画廊显示。""" + import shutil as _sh + ts_dir = Path(tempfile.gettempdir()) / "swap_display" / str(int(time.time() * 1000)) + ts_dir.mkdir(parents=True, exist_ok=True) + out = [] + for p, label in items: + try: + src = Path(p) + dst = ts_dir / src.name + _sh.copy2(src, dst) + out.append((str(dst), label)) + except Exception: + out.append((p, label)) + return out + + +# 打标中应排除的固定模板词/结构性词(不算"可选取的测试 prompt 关键词") +_FIXED_CAP_WORDS = { + "lm_face_v1", + # 角度完整词不在这里过滤(应参与统计归入角度分类);只过滤拆词碎片 + "front", "three-quarter", "side", "high", "low", "angle", "view", "degree", + # 构图模板(完整 + 碎片) + "photorealistic portrait", "half body shot", "full body shot", + "photorealistic", "portrait", "half", "body", "shot", "full", + # 光线 + "soft natural lighting", "natural lighting", "bright lighting", "studio lighting", + "soft", "natural", "lighting", "bright", "studio", + # 表情模板词(neutral 是默认模板词,其余表情是可选取特征) + "neutral expression", "neutral", "expression", +} + + +def _llm_optimize_captions(captions, backend="omlx-32b", timeout=180): + """一键 LLM 整理所有 caption(批量一起提交,让 LLM 合并同类项/同义词): + - 长句拆成逗号分隔的短 tag + - 合并同义词("长发高马尾" 和 "高马尾长发" 统一) + - 保留触发词、角度、构图等结构 + 返回 {原caption: 优化后caption};失败返回 None。 + """ + import json as _json + import urllib.request + if not captions: + return {} + # 构造批量整理请求:所有 caption 编号,LLM 返回同名编号的优化结果 + lines = [] + for i, c in enumerate(captions, 1): + lines.append(f"[{i}] {c}") + prompt = ( + "你是 LoRA 训练打标整理器。下面是一批人物照片的 caption,每行格式 `[编号] 内容`。\n" + "请统一优化每个 caption:\n" + "1. 把长句描述拆成逗号分隔的短 tag(如 '黑色齐刘海高马尾长发粉色蝴蝶结发饰' → '齐刘海, 高马尾, 长发, 蝴蝶结发饰')\n" + "2. 合并同义词/同类项(如 '长发高马尾' 和 '高马尾长发' 统一为一种写法;'粉色的蝴蝶结' 和 '粉色蝴蝶结' 统一)\n" + "3. 保留开头的触发词 lm_face_v1、角度(front view 等)、构图(photorealistic portrait 等)不动\n" + "4. 每个词段独立、简洁、不重复,逗号分隔,不要多余解释\n" + "5. 不要输出任何思考过程/解释/开场白,直接按编号输出结果行\n" + "严格按同样编号输出每行:`[编号] 优化后的caption`,保持顺序和数量一致。\n\n" + + "\n".join(lines) + ) + try: + # 按后端发请求(sensenova 需 Bearer,omlx 无 auth,ollama 用 /api/chat 格式) + from face_checker import _resolve_model, SENSENOVA_KEY, OLLAMA_MODEL + url, model = _resolve_model(backend) + payload = { + "model": model, + "messages": [{"role": "user", "content": prompt}], + "max_tokens": 4096, + } + headers = {"Content-Type": "application/json"} + if backend == "sensenova": + headers["Authorization"] = f"Bearer {SENSENOVA_KEY}" + req = urllib.request.Request(url, data=_json.dumps(payload).encode(), + headers=headers, method="POST") + with urllib.request.urlopen(req, timeout=timeout) as resp: + result = _json.loads(resp.read()) + msg = result["choices"][0]["message"] + text = (msg.get("content") or "").strip() + if not text: + # sensenova 等 thinking 模型可能把内容放 reasoning + text = (msg.get("reasoning") or "").strip() + # 去掉思考过程前缀 + text = re.sub(r"^Thinking\s*Process:?\s*", "", text, flags=re.I) + # 解析 [编号] 行 + import re + mapping = {} + for m in re.finditer(r"\[(\d+)\]\s*(.+)", text): + idx = int(m.group(1)) + cap = m.group(2).strip() + if 1 <= idx <= len(captions): + mapping[captions[idx - 1]] = cap + return mapping if mapping else None + except Exception: + return None + +# 打标关键字分类词典(关键词 → 类别)。类别顺序即展示顺序。匹配规则:完整词或包含词。 +# 每类一组 (关键词, 匹配方式),匹配方式: "=" 精确 / "in" 包含 +_KEYWORD_CATS = [ + ("表情", [("微笑", "="), ("露齿笑", "="), ("大笑", "="), ("中性", "="), ("严肃", "="), ("惊讶", "=")]), + ("水平角度", [("front view", "="), ("three-quarter view", "="), ("side view", "="), + ("正面", "="), ("前侧", "="), ("侧面", "=")]), + ("垂直角度", [("high angle view", "="), ("low angle view", "="), + ("平视", "="), ("俯拍", "="), ("仰拍", "=")]), + ("构图", [("photorealistic portrait", "="), ("half body shot", "="), ("full body shot", "="), + ("特写", "="), ("半身", "="), ("全身", "=")]), + ("妆", [("素颜", "="), ("浓妆口红", "="), ("浓妆", "="), ("淡妆", "="), ("素妆", "="), ("妆", "in")]), + ("发型", [("马尾", "in"), ("直发", "in"), ("卷发", "in"), ("长发", "in"), ("短发", "in"), + ("束发", "in"), ("盘发", "in"), ("扎发", "in"), ("齐刘海", "in"), ("刘海", "in"), + ("丸子头", "in"), ("发箍", "in"), ("高发髻", "in")]), + ("配饰", [("鸭舌帽", "in"), ("墨镜", "in"), ("发饰", "in"), ("发夹", "in"), ("发卡", "in"), + ("钻石项链", "in"), ("四叶草项链", "in"), ("项链", "in"), ("蝴蝶结", "in"), + ("丝带", "in"), ("耳环", "in")]), + ("姿势", [("坐姿", "in"), ("坐", "="), ("蹲姿", "in"), ("站立", "="), ("站", "="), + ("双手扶肩", "in"), ("双手比划", "in"), ("举玩偶", "in"), ("叉腰", "in"), + ("提裙", "in"), ("双臂张开", "in"), ("比耶", "in"), ("牵手", "in"), ("抱", "in")]), + ("服装", [("裙", "in"), ("礼服", "in"), ("上衣", "in"), ("外套", "in"), ("牛仔裤", "in"), + ("短裤", "in"), ("内搭", "in"), ("套装", "in"), ("衬衫", "in"), ("T恤", "in"), + ("T恤", "in"), ("抹胸", "in"), ("蕾丝", "in"), ("手套", "in"), ("高跟鞋", "in"), + ("鞋", "in"), ("帽", "in")]), + ("光线/背景", [("自然光", "in"), ("室内光", "in"), ("明亮光", "in"), ("柔和光", "in"), + ("灯光", "in"), ("房间", "in"), ("草地", "in"), ("花园", "in"), ("楼梯", "in"), + ("墙面", "in"), ("沙发", "in"), ("玩偶", "in"), ("旋转木马", "in"), + ("凯蒂猫", "in"), ("Hello Kitty", "in"), ("画框", "in"), ("相框", "in")]), +] + +def _kw_has(cap, word): + """词条级判断:caption 中是否存在独立词条 == word(按 , 、 + / 拆分,与统计口径一致)。 + 「长发」只匹配独立词条「长发」,不会误伤「黑色长发」。""" + import re as _re + word = (word or "").strip() + if not word: + return False + cap = (cap or "").replace("Hello Kitty", "HelloKitty").replace("hello kitty", "HelloKitty") + word = word.replace("Hello Kitty", "HelloKitty") + for seg in _re.split(r"[,,]", cap): + for p in _re.split(r"[+/、]", seg): + if p.strip() == word: + return True + return False + + +def _kw_replace_all(cap, old_word, new_word): + """词条级替换:caption 中所有独立词条 == old_word 替换为 new_word。 + 保留段内分隔符(+ / 、)和原始结构;不匹配的词条原样保留。""" + import re as _re + old_word = (old_word or "").strip() + new_word = new_word or "" + # 归一保护形式:caption 与目标词都按统计分词口径处理(Hello Kitty → HelloKitty) + cap_norm = (cap or "").replace("Hello Kitty", "HelloKitty").replace("hello kitty", "HelloKitty") + old_norm = old_word.replace("Hello Kitty", "HelloKitty") + out_segs = [] + for seg in _re.split(r"[,,]", cap_norm): + if not seg.strip(): + out_segs.append(seg) + continue + lead = seg[: len(seg) - len(seg.lstrip())] # 段前导空格(保留格式 ", xxx") + parts = _re.split(r"([+/、])", seg.strip()) # 保留分隔符 + rebuilt = "" + for p in parts: + if p in ("+", "/", "、"): # 分隔符原样 + rebuilt += p + elif p.strip() == old_norm: # 词条级精确命中 + rebuilt += new_word + else: + rebuilt += p + out_segs.append(lead + rebuilt) + # 还原保护形式,保持与 caption 原文一致的 "Hello Kitty" 写法 + return ",".join(out_segs).replace("HelloKitty", "Hello Kitty") + + +def _categorize_keyword(w): + """把关键词归入类别,返回类别名或 None(未分类)。""" + wl = w.lower() + for cat, rules in _KEYWORD_CATS: + for kw, mode in rules: + if mode == "=" and wl == kw.lower(): + return cat + if mode == "in" and kw.lower() in wl: + return cat + return None + +def _kw_chip(w, n, hl): + """统计词条 chip:可拖拽/可双击,data-word 存原词(dataset 自动解码 HTML 实体)""" + import html as _html + esc = _html.escape(w, quote=True) + color = "#fbbf24;font-weight:700" if hl else "#e5e7eb" + return (f'{_html.escape(w)}' + f'×{n}') + + +def _label_backup_path(base_dir): + """③打标 caption 快照路径:随素材目录走({素材目录}/.caption_backup/captions.json)。 + 放在素材目录下而非工具目录——复制/移动素材时备份跟随,多个素材目录互不干扰。""" + return Path(clean_path(base_dir) or ".") / ".caption_backup" / "captions.json" + + +def _backup_label_captions(base_dir): + """加载打标目录时,把当前所有 txt 的 caption 快照备份到 LABEL_BACKUP(每次覆盖)。 + 返回备份的 txt 数量。供「重置所有修改」按钮恢复。""" + base_dir = clean_path(base_dir) + if not base_dir or not Path(base_dir).exists(): + return 0 + txts = sorted(p for p in Path(base_dir).rglob("*.txt") + if ".bak" not in p.name and "淘汰" not in str(p) and "未选中" not in str(p)) + snap = {} + for t in txts: + try: + snap[str(t)] = t.read_text(encoding="utf-8") + except Exception: + continue + bp = _label_backup_path(base_dir) + bp.parent.mkdir(parents=True, exist_ok=True) + bp.write_text(json.dumps(snap, ensure_ascii=False, indent=1), encoding="utf-8") + return len(snap) + + +def _keyword_stats_from_texts(texts): + """统计给定 caption 文本列表的关键字频率,按类别分类展示(树状)。 + 返回 HTML:类别 → 关键词×次数。词条支持拖拽/双击(由页面 JS 处理)。""" + from collections import Counter, defaultdict + import re + cnt = Counter() # 词 -> 次数(未分类的词也统计,最后归"其他") + for cap in texts: + cap = (cap or "").strip() + if not cap: + continue + # 先保护 "Hello Kitty" 整体(不按空格拆散) + cap = cap.replace("Hello Kitty", "HelloKitty").replace("hello kitty", "HelloKitty") + # 保护完整角度词(不按空格拆成碎片) + for _aw in ("front view", "three-quarter view", "side view", "high angle view", "low angle view"): + cap = cap.replace(_aw, _aw.replace(" ", "_")) + for seg in re.split(r"[,,]", cap): + seg = seg.strip() + if not seg: + continue + for w in re.split(r"[\s/、]", seg): + w = w.strip(" .,;::") + if not w or len(w) < 2: + continue + wl = w.lower() + if wl in _FIXED_CAP_WORDS or wl.startswith("lm_face"): + continue + # 还原 HelloKitty 和角度下划线(白名单:只还原受保护的角度词, + # 其他含下划线的词保持原样——否则统计显示与 caption 不一致,拖拽/双击替换会匹配失败) + # HelloKitty 可能内嵌在词条里(如 "粉色沙发及HelloKitty玩偶"),都要还原成 "Hello Kitty" + if "HelloKitty" in w: + w = w.replace("HelloKitty", "Hello Kitty") + elif w in ("front_view", "three-quarter_view", "side_view", + "high_angle_view", "low_angle_view"): + w = w.replace("_", " ") + cnt[w] += 1 + if not cnt: + return '⚠️ 未提取到有效关键词' + # 按类别归类 + by_cat = defaultdict(Counter) + others = Counter() + for w, n in cnt.items(): + cat = _categorize_keyword(w) + if cat: + by_cat[cat][w] = n + else: + others[w] = n + # 组装树状 HTML + parts = [f'
' + f'📊 打标关键字统计(按类别)' + f'({len(texts)} 张素材)' + f'
' + f'🖱 拖拽某一项到另一项 = 批量替换(目标→被拖项);双击某项 = 修改后批量替换
'] + for cat, _rules in _KEYWORD_CATS: + if cat not in by_cat: + continue + top = by_cat[cat].most_common(15) + inner = " ".join(_kw_chip(w, n, n >= 5) for w, n in top) + parts.append(f'
▸ {cat} ' + f'({sum(by_cat[cat].values())}次)
' + f'{inner}
') + if others: + top_oth = others.most_common(15) + inner = " ".join(_kw_chip(w, n, False) for w, n in top_oth) + parts.append(f'
▸ 其他
' + f'{inner}
') + parts.append('
') + return "".join(parts) + + +def _keyword_stats_selected(base_dir): + """③打标 统计:只统计「选中图」的 caption(与 _collect_label_images 同一口径)。 + 不含 train_dataset 副本/未选中/淘汰——避免同一 caption 被数两遍。""" + base_dir = clean_path(base_dir) + if not base_dir or not Path(base_dir).exists(): + return '❌ 目录不存在' + imgs = _collect_label_images(base_dir) + if not imgs: + return '⚠️ 没有可统计的选中图' + texts = [] + for img_path, _kind in imgs: + txt = Path(img_path).with_suffix(".txt") + try: + cap = txt.read_text(encoding="utf-8").strip() if txt.exists() else "" + except Exception: + cap = "" + texts.append(cap) + return _keyword_stats_from_texts(texts) + + +def _keyword_stats(train_ds): + """读目录下所有 txt → 转文本列表 → _keyword_stats_from_texts。 + 支持:训练集目录(img_*.txt)或打标目录(特写/半身/全身 子目录)。""" + train_ds = clean_path(train_ds) + if not train_ds or not Path(train_ds).exists(): + return '❌ 目录不存在' + # 收集目录下所有 *.txt(递归子目录),跳过 .bak 和 auto 流水线内部文件 + txts = sorted(p for p in Path(train_ds).rglob("*.txt") + if ".bak" not in p.name and "淘汰" not in str(p) and "未选中" not in str(p)) + if not txts: + return '⚠️ 没有可统计的 txt(打标目录或训练集目录)' + texts = [] + for t in txts: + try: + texts.append(t.read_text(encoding="utf-8").strip()) + except Exception: + continue + return _keyword_stats_from_texts(texts) + + + +def _prepare_trainset(base_dir): + """一键整理训练集:把 auto 输出目录的 已选素材(特写/半身/全身 + caption)复制成训练集 img_001..。 + ⚠️ 训练集目录必须从 base_dir 派生(base_dir/train_dataset),不能读 train_config.json 的旧值—— + 否则换目录后会把新素材写进上一次的旧路径(bug:2026-08-09 修复)。""" + from PIL import Image as _Img + base_dir = clean_path(base_dir) + if not base_dir or not Path(base_dir).exists(): + return '❌ 目录不存在' + selected, _ = _swap_collect(base_dir) + if not selected: + return '❌ 没有已选素材' + train_ds = Path(base_dir) / "train_dataset" # 永远跟随当前素材目录 + cfg = _train_cfg() + train_ds.mkdir(parents=True, exist_ok=True) + for f in train_ds.glob("*"): + if f.is_file(): + f.unlink() + copied, skipped = 0, [] + for i, (img_path, _label) in enumerate(selected, 1): + txt = Path(img_path).with_suffix(".txt") + if not txt.exists(): + skipped.append(Path(img_path).name) + continue + stem = f"img_{i:03d}" + _Img.open(img_path).convert("RGB").save(train_ds / f"{stem}.jpg", quality=95) + (train_ds / f"{stem}.txt").write_text(txt.read_text(encoding="utf-8").strip(), encoding="utf-8") + copied += 1 + cfg["train_dataset"] = str(train_ds) + TRAIN_CONFIG.write_text(json.dumps(cfg, ensure_ascii=False, indent=1), encoding="utf-8") + msg = (f'✅ 已整理 {copied} 张到 {train_ds}' + f'
➡️ 下一步:到 ④ 训练 Tab(目录已自动同步),先点「🔄 重新预缓存」,再启动训练') + if skipped: + msg += f'
⚠️ {len(skipped)} 张缺 caption({", ".join(skipped[:5])}),先保存再整理' + return msg + + +def _find_source_by_orb(crop_path, base_dir): + """用 ORB 特征匹配找到裁剪图的源图(在 未选中/换出区/final 里),返回源文件路径或 None""" + import cv2 as _cv2 + import numpy as np # 模块顶部未导入 numpy,此处必须局部导入(否则 NameError 被 try 吞掉 → 永远返回 None) + try: + orb = _cv2.ORB_create(1500) + bf = _cv2.BFMatcher(_cv2.NORM_HAMMING) + from PIL import Image as _I + crop_rgb = _I.open(crop_path).convert("RGB") + crop_gray = _cv2.cvtColor(np.array(crop_rgb), _cv2.COLOR_RGB2GRAY) + k1, d1 = orb.detectAndCompute(crop_gray, None) + if d1 is None or len(k1) < 10: + return None + base = Path(base_dir) + ud = base / "未选中" + candidates = [] + if ud.exists(): + for p in ud.iterdir(): + if p.suffix.lower() in {".jpg", ".jpeg", ".png", ".webp"} and p.is_file(): + candidates.append(p) + fin = Path(r"K:\AI\training\ldf\singled\final") + if fin.exists(): + for p in fin.iterdir(): + if p.suffix.lower() in {".jpg", ".jpeg", ".png"} and p.is_file(): + candidates.append(p) + best, bn = None, 0 + for p in candidates: + try: + rgb = _I.open(p).convert("RGB") + gray = _cv2.cvtColor(np.array(rgb), _cv2.COLOR_RGB2GRAY) + k2, d2 = orb.detectAndCompute(gray, None) + if d2 is None: + continue + ms = bf.knnMatch(d1, d2, k=2) + g = [m for m, n in ms if m.distance < 0.75 * n.distance] if ms else [] + if len(g) > bn: + bn, best = len(g), p + except Exception: + continue + return best if bn >= 30 else None + except Exception: + return None + + +def _prep_candidate(cand_path, kind, backend): + """候选图预处理:检测人脸 + 按类型裁剪(特写裁脸/全身构图/半身原图)+ 统一 caption 生成。 + 失败抛异常(调用方保证不动任何文件)。""" + import numpy as np + img = fc.load_image(cand_path) + rgb = np.array(img.convert("RGB")) + faces = fc._detect_faces_fast(rgb, _swap_detector()) + if not faces: + # 检测器漏检但 VLM 可能确认有脸(如侧脸/遮挡):先让 VLM 确认。 + # vlm_caption 返回完整 caption 说明确认有人脸 → 整图作为构图素材加入(不裁脸);否则报错。 + caption, angle_en = fc.vlm_caption(str(cand_path), kind, backend=backend, trigger=TRIGGER_DEFAULT) + if caption: + # VLM 确认有脸:构图降级为半身(原图),不裁脸 + return img, caption, angle_en or "front view" + raise ValueError(f"{Path(cand_path).name} 未检测到人脸(且 VLM 无法确认)") + f = max(faces, key=lambda x: x[2] * x[3]) if len(faces) > 1 else faces[0] + if kind == "特写": + crop, _ = fc.crop_face_portrait(img, f, margin=2.5) + elif kind == "全身": + crop, _ = fc.crop_fullbody(img, f) + else: + crop = img + caption, angle_en = fc.vlm_caption(str(cand_path), kind, backend=backend, trigger=TRIGGER_DEFAULT) + if not caption: + angle_en = fc.face_angle(f)[1] + caption = f"{TRIGGER_DEFAULT}, {angle_en} {fc.AUTO_TEMPLATES[kind]}" + return crop, caption, angle_en + + +def build_swap_tab(demo, vlm_backend): + with gr.Tab("② 候选换图"): + gr.Markdown("**② 候选换图**:左边点选要换出的已选素材,右边点选要换入的未选中候选 → 点交换;" + "点「↩️ 取消已选」把选中的已选素材移回未选中候选。" + "换入时自动按类型裁剪(特写裁脸/全身构图)+ VLM 重新打标;换出的图移回未选中。" + "➡️ 换完到 **③ 打标** 审改 caption") + with gr.Row(): + base_tb = gr.Textbox(label="auto 输出目录(① 跑完自动填好,含 特写/半身/全身/未选中)", scale=3, + value=state_val("auto_out", "")) + load_btn = gr.Button("🔄 加载", scale=1) + with gr.Row(): + prepare_btn = gr.Button("📦 整理训练集(💡 打标全部确认后再点:把 已选素材+caption 复制成训练集 img_001.. → 训练目录)", + variant="primary") + status_html = gr.HTML('先加载目录') + # ── 已选素材筛选(按 水平/垂直/表情/构图 4 维过滤,只影响 已选画廊+操作列表)── + with gr.Row(elem_classes="filter-row"): + gr.Markdown("**筛选已选**", elem_classes="x-label") + f_h = gr.Dropdown(["全部"] + _ANGLE_H_OPTS, value="全部", container=False, scale=1, elem_classes="fld-dd", info="水平") + f_v = gr.Dropdown(["全部"] + _ANGLE_V_OPTS, value="全部", container=False, scale=1, elem_classes="fld-dd", info="垂直") + f_expr = gr.Dropdown(["全部"] + _EXPR_OPTS, value="全部", container=False, scale=1, elem_classes="fld-dd", info="表情") + f_comp = gr.Dropdown(["全部"] + _COMP_OPTS, value="全部", container=False, scale=1, elem_classes="fld-dd", info="构图") + filter_info = gr.HTML('未筛选') + clear_filter = gr.Button("✕ 清除筛选", size="sm", scale=0) + with gr.Row(): + with gr.Column(scale=1): + sel_gal = gr.Gallery(label="已选素材(点击选择要换出的)", columns=4, height=420, elem_id="sel_gallery", + object_fit="contain", allow_preview=True, show_fullscreen_button=True) + with gr.Column(scale=1): + cand_gal = gr.Gallery(label="未选中候选(点击选择要换入的)", columns=4, height=420, elem_id="cand_gallery", + object_fit="contain", allow_preview=True, show_fullscreen_button=True) + with gr.Row(): + with gr.Column(scale=1): + with gr.Accordion("🎛️ 角度/表情/构图 + Caption(可折叠,选完图后点右侧按钮或下方手动裁剪)", open=True): + with gr.Row(elem_classes="field-row"): + gr.Markdown("**水平角度**", elem_classes="x-label") + h_dd = gr.Dropdown(_ANGLE_H_OPTS, value="front view", container=False, scale=1, elem_classes="fld-dd", allow_custom_value=True) + h_dist = gr.HTML('正面:0 前侧:0 侧面:0') + with gr.Row(elem_classes="field-row"): + gr.Markdown("**垂直角度**", elem_classes="x-label") + v_dd = gr.Dropdown(_ANGLE_V_OPTS, value="平视", container=False, scale=1, elem_classes="fld-dd", allow_custom_value=True) + v_dist = gr.HTML('平视:0 俯拍:0 仰拍:0') + with gr.Row(elem_classes="field-row"): + gr.Markdown("**表情**", elem_classes="x-label") + expr_dd = gr.Dropdown(_EXPR_OPTS, value="微笑", container=False, scale=1, elem_classes="fld-dd", + allow_custom_value=True) # 页面加载状态恢复时下拉可能传空值,允许临时值防报错 + expr_dist = gr.HTML('微笑:0 露齿笑:0 大笑:0 中性:0 严肃:0 惊讶:0') + with gr.Row(elem_classes="field-row"): + gr.Markdown("**构图**", elem_classes="x-label") + comp_dd = gr.Dropdown(_COMP_OPTS, value="特写", container=False, scale=1, elem_classes="fld-dd", allow_custom_value=True) + comp_dist = gr.HTML('特写:0 半身:0 全身:0') + advice_html = gr.HTML('加载后显示素材建议(总数/角度/表情/构图是否合理)') + cap_tb = gr.Textbox(label="完整 Caption", lines=3, interactive=True, + placeholder="点选图后显示其 caption(可直接编辑)") + with gr.Row(): + cap_regen = gr.Button("🔄 重新打标(用顶部 VLM)", scale=1) + cap_save = gr.Button("💾 保存 Caption", variant="primary", scale=1) + cap_status = gr.HTML('点选图后编辑 caption') + with gr.Column(scale=1): + cancel_btn = gr.Button("↩️ 取消已选(移入候选)", variant="secondary", elem_id="btn_cancel_sel") + swap_btn = gr.Button("⇄ 交换选中项", variant="primary") + add_btn = gr.Button("➕ 添加选中候选(不替换,追加为新素材)", elem_id="btn_add_cand") + kind_dd = gr.Dropdown(["特写", "半身", "全身"], value="特写", elem_id="cand_kind_dd", + label="添加为类型(系统按类型自动裁剪+打标)") + sel_idx = gr.State(-1) + cand_idx = gr.State(-1) + sel_paths = gr.State([]) + cand_paths = gr.State([]) + + # ---- 手动裁剪面板:点选任一图后在此精修 ---- + with gr.Accordion("✂️ 手动裁剪(点选 已选/候选 图后,在这里调整并保存)", open=True): + with gr.Row(): + with gr.Column(scale=1): + crop_src = gr.Image(label="待裁剪图(点选左/右栏图片自动载入)", type="filepath", height=340) + with gr.Row(): + cr_x0 = gr.Slider(0, 100, value=0, step=1, label="左 %") + cr_y0 = gr.Slider(0, 100, value=0, step=1, label="上 %") + with gr.Row(): + cr_x1 = gr.Slider(0, 100, value=100, step=1, label="右 %") + cr_y1 = gr.Slider(0, 100, value=100, step=1, label="下 %") + with gr.Column(scale=1): + crop_prev = gr.Image(label="裁剪预览", type="filepath", height=340) + crop_file = gr.State("") + crop_btn = gr.Button("✂️ 应用裁剪(自动备份原图)", variant="primary") + crop_status = gr.HTML('点选图片后设置裁剪框') + + def load(base_dir): + base_dir = clean_path(base_dir) + if not base_dir or not Path(base_dir).exists(): + return [], [], [], [], '❌ 目录不存在', '' + selected, cands = _swap_collect(base_dir) + sp = [s[0] for s in selected] + cp = [c[0] for c in cands] + # 画廊显示用时间戳镜像(路径每次不同,强制浏览器重拉) + display_sel = _bust(selected) + display_cand = _bust(cands) + msg = f'✅ 已选 {len(sp)} 张 / 候选 {len(cp)} 张' + return display_sel, display_cand, sp, cp, msg, *_stats_fields(base_dir) + + def _filter_selected(base_dir, fh, fv, fexpr, fcomp): + """按 水平/垂直/表情/构图 4 维筛选已选素材。 + 返回 (已选画廊显示, 过滤后的 sel_paths, 筛选状态文案)。 + 只读 caption 解析,不改任何文件;筛选结果作为后续点选/交换/取消的操作列表。""" + base_dir = clean_path(base_dir) + if not base_dir or not Path(base_dir).exists(): + return gr.update(), [], '❌ 目录不存在' + selected, _ = _swap_collect(base_dir) + sp = [s[0] for s in selected] + if fh == "全部" and fv == "全部" and fexpr == "全部" and fcomp == "全部": + return _bust(selected), sp, f'未筛选 · 已选 {len(sp)} 张' + out = [] + for real in sp: + cap = "" + txt = Path(real).with_suffix(".txt") + if txt.exists(): + try: + cap = txt.read_text(encoding="utf-8").strip() + except Exception: + cap = "" + d = _parse_caption(cap) + if fh != "全部" and d["h"] != fh: + continue + if fv != "全部" and d["v"] != fv: + continue + if fexpr != "全部" and fexpr not in d["expr"]: + continue + if fcomp != "全部" and d["comp"] != fcomp: + continue + out.append((real, Path(real).name)) + conds = [c for c, v in (("水平", fh), ("垂直", fv), ("表情", fexpr), ("构图", fcomp)) if v != "全部"] + info = (f'筛选 {len(out)}/{len(sp)} 张' + f'({"、".join(conds)})') + return _bust(out), [p for p, _ in out], info + + def _clear_filter(base_dir): + """清除筛选:恢复全部已选""" + return _filter_selected(base_dir, "全部", "全部", "全部", "全部") + + def _reapply_filter(base_dir, fh, fv, fexpr, fcomp, cands): + """写操作后统一重刷画廊:已选按当前筛选条件过滤,候选保持全量。 + 返回 (_bust(已选筛选结果), _bust(cands全量), 过滤后paths, 全量cand_paths)""" + sel_disp, sp2, _ = _filter_selected(base_dir, fh, fv, fexpr, fcomp) + cp2 = [c[0] for c in cands] + return sel_disp, _bust(cands), sp2, cp2 + + def _load_crop(base_dir, paths, idx): + """合并处理器:evt.index 直接用(避免 State 竞态导致 caption 错位)""" + if idx < 0 or idx >= len(paths): + return None, "", "", "", "front view", "平视", "微笑", "特写" + real = paths[idx] + cap = "" + txt = Path(real).with_suffix(".txt") + if txt.exists(): + cap = txt.read_text(encoding="utf-8").strip() + d = _parse_caption(cap) + return _bust([(real, Path(real).name)])[0][0], real, cap, \ + f'{Path(real).name}', d["h"], d["v"], d["expr"], d["comp"] + + def on_sel(evt: gr.SelectData, base_dir, sp): + return evt.index, *_load_crop(base_dir, sp, evt.index) + + def on_cand(evt: gr.SelectData, base_dir, cp): + return evt.index, *_load_crop(base_dir, cp, evt.index) + + def cap_rebuild(h, v, expr, comp, cap): + """任一下拉框变化 → 重建完整 caption(页面加载状态恢复时下拉可能传空值,需兜底)""" + d = _parse_caption(cap) + if expr not in _EXPR_OPTS: + expr = d["expr"] if d["expr"] in _EXPR_OPTS else "微笑" + if h not in _ANGLE_H_OPTS: + h = d["h"] if d["h"] in _ANGLE_H_OPTS else "front view" + if v not in _ANGLE_V_OPTS: + v = d["v"] if d["v"] in _ANGLE_V_OPTS else "平视" + if comp not in _COMP_OPTS: + comp = d["comp"] if d["comp"] in _COMP_OPTS else "特写" + return _build_caption(h, v, comp, expr, d["tail"]) + + def cap_regen_fn(real, backend): + """用 VLM 重新生成 caption(按图类型自动决定特写/半身描述)""" + if not real or not Path(real).exists(): + return gr.update(), '❌ 请先点选一张图' + kind = Path(real).parent.name # 特写/半身/全身 + if kind not in ("特写", "半身", "全身"): + return gr.update(), f'❌ 无法识别类型: {kind}' + caption, angle_en = fc.vlm_caption(str(real), kind, backend=backend, trigger=TRIGGER_DEFAULT) + if not caption: + return gr.update(), f'⚠️ VLM 判定失败({backend}),未生成' + return caption, f'✅ 已重新打标({backend},角度 {angle_en})' + + def cap_save_fn(base_dir, real, cap_text, comp, sp, cp, fh, fv, fexpr, fcomp): + """保存 caption;若构图与当前目录不符,则移动文件到对应目录并重命名(face/half/full)。 + 保存后同步界面当前图:若该图仍在筛选结果则保持,否则自动切到筛选后第一张。""" + if not real or not Path(real).exists(): + return (gr.update(), gr.update(), sp, cp, gr.update(), gr.update(), gr.update(), + '❌ 请先点选一张图', + gr.update(), gr.update(), gr.update(), gr.update(), + gr.update(), gr.update(), gr.update(), gr.update(), gr.update()) + base_dir = clean_path(base_dir) + p = Path(real) + cur_dir = p.parent.name # 特写/半身/全身 + prefix_map = {"特写": "face", "半身": "half", "全身": "full"} + msg_extra = "" + if cur_dir != comp: + # 需要移动:目标目录 + 新编号 + tgt_dir = Path(base_dir) / comp + tgt_dir.mkdir(parents=True, exist_ok=True) + pre = prefix_map.get(comp, "face") + nums = [] + for f in tgt_dir.glob(f"{pre}_*.jpg"): + parts = f.stem.split("_") + if len(parts) == 2 and parts[1].isdigit(): + nums.append(int(parts[1])) + n = max(nums) + 1 if nums else 1 + new_name = f"{pre}_{n:03d}.jpg" + new_path = tgt_dir / new_name + p.rename(new_path) + new_path.with_suffix(".txt").write_text(cap_text.strip(), encoding="utf-8") + msg_extra = f';已移到 {comp}/{new_name}(原 {cur_dir}/{p.name})' + real = str(new_path) + else: + Path(real).with_suffix(".txt").write_text(cap_text.strip(), encoding="utf-8") + # 刷新画廊(已选按当前筛选条件重过滤) + selected, cands = _swap_collect(base_dir) + sel_disp, cand_disp, sp2, cp2 = _reapply_filter(base_dir, fh, fv, fexpr, fcomp, cands) + saved_real = str(real) + # 同步界面当前图:若保存的图仍在筛选结果中 → 保持它;若已移出筛选 → 自动切到筛选后第一张 + if saved_real in sp2: + cur = saved_real + status_note = f'✅ Caption 已保存{msg_extra}' + elif sp2: + cur = sp2[0] + status_note = (f'⚠️ 原图已移出当前筛选(属性不符),已自动切到筛选后的第一张:' + f'{Path(cur).name}。Caption 已保存{msg_extra}') + else: + cur = "" + status_note = f'⚠️ Caption 已保存{msg_extra},但当前筛选下已无已选素材' + if cur: + cap2 = Path(cur).with_suffix(".txt").read_text(encoding="utf-8").strip() if Path(cur).with_suffix(".txt").exists() else "" + d2 = _parse_caption(cap2) + crop_src_v = _bust([(cur, Path(cur).name)])[0][0] + else: + cap2, d2, crop_src_v = "", _parse_caption(""), None + return sel_disp, cand_disp, sp2, cp2, crop_src_v, cur, cap2, \ + f'{status_note}', \ + d2["h"], d2["v"], d2["expr"], d2["comp"], \ + *_stats_fields(base_dir) + + def crop_preview(img_path, x0, y0, x1, y1): + if not img_path or not Path(img_path).exists(): + return None + from PIL import Image as _I + im = _I.open(img_path) + w, h = im.size + bx0 = int(w * x0 / 100); by0 = int(h * y0 / 100) + bx1 = int(w * x1 / 100); by1 = int(h * y1 / 100) + if bx1 - bx0 < 20 or by1 - by0 < 20: + return str(img_path) + crop = im.crop((bx0, by0, bx1, by1)) + tmp = Path(tempfile.gettempdir()) / f"swap_crop_preview_{int(time.time())}.jpg" + crop.save(tmp, quality=95) + return str(tmp) + + def crop_apply(base_dir, img_path, x0, y0, x1, y1, sp, cp, fh, fv, fexpr, fcomp): + # img_path 来自 crop_file State(真实路径,非显示副本) + if not img_path or not Path(img_path).exists(): + return gr.update(), gr.update(), gr.update(), gr.update(), \ + '❌ 请先点选一张图片', gr.update(), gr.update(), gr.update(), gr.update(), gr.update() + p = Path(img_path) + from PIL import Image as _I + im = _I.open(p) + w, h = im.size + bx0 = int(w * x0 / 100); by0 = int(h * y0 / 100) + bx1 = int(w * x1 / 100); by1 = int(h * y1 / 100) + if bx1 - bx0 < 20 or by1 - by0 < 20: + return gr.update(), gr.update(), gr.update(), gr.update(), \ + '❌ 裁剪区域太小(至少 20px)', gr.update(), gr.update(), gr.update(), gr.update(), gr.update() + crop = im.crop((bx0, by0, bx1, by1)) + bak = p.with_name(f"{p.stem}.bak_{int(time.time())}.jpg") + if not bak.exists(): + p.rename(bak) + crop.save(p, quality=95) + # 刷新画廊 + 预览(带时间戳防 Gradio 缓存);已选按当前筛选条件重过滤 + base_dir = clean_path(base_dir) + selected, cands = _swap_collect(base_dir) + sel_disp, cand_disp, sp2, cp2 = _reapply_filter(base_dir, fh, fv, fexpr, fcomp, cands) + fresh = f"{p}?t={int(time.time())}" if False else str(p) # 路径本身变化不大,直接返回 + msg = (f'✅ 已裁剪 {p.name} → {crop.size[0]}×{crop.size[1]} ' + f'(原图备份 {bak.name})') + return sel_disp, cand_disp, sp2, cp2, msg, *_stats_fields(base_dir) + + def do_remove(base_dir, si, sp, fh, fv, fexpr, fcomp): + """移除已选素材:删除裁剪版,恢复原图到未选中(换出_ 文件恢复原名,final 源复制进来)""" + if si < 0 or si >= len(sp): + return gr.update(), gr.update(), sp, sp, \ + '❌ 请先点选一张已选素材', gr.update(), gr.update(), gr.update(), gr.update(), gr.update() + base_dir = clean_path(base_dir) + p = Path(sp[si]) + if not p.exists(): + return gr.update(), gr.update(), sp, sp, '❌ 文件不存在', gr.update(), gr.update(), gr.update(), gr.update(), gr.update() + removed_name = p.name + # 1. 找源图(在删除前,用 ORB) + src = _find_source_by_orb(str(p), base_dir) + # 2. 删除裁剪版 + caption + p.unlink() + txt = p.with_suffix(".txt") + if txt.exists(): + txt.unlink() + # 3. 恢复原图到未选中 + unused = Path(base_dir) / "未选中" + unused.mkdir(exist_ok=True) + restored = None + if src is not None: + if src.parent.name == "final": + # 源在 final:复制到未选中(原名) + dst = unused / src.name + if not dst.exists(): + import shutil as _sh + _sh.copy2(src, dst) + restored = src.name + else: + # 源在未选中:若是 换出_ 前缀,恢复原名 + if src.name.startswith("换出_"): + orig_name = src.name.split("_", 2)[-1] if src.name.count("_") >= 2 else src.name + dst = unused / orig_name + if not dst.exists(): + src.rename(dst) + restored = orig_name + else: + src.unlink() + restored = orig_name + else: + restored = src.name # 已在未选中,无需动 + else: + restored = "(源未找到,已移除裁剪版)" + # 4. 刷新画廊(已选按当前筛选条件重过滤) + selected, cands = _swap_collect(base_dir) + sel_disp, cand_disp, sp2, cp2 = _reapply_filter(base_dir, fh, fv, fexpr, fcomp, cands) + msg = (f'✅ 已移除 {removed_name};原图恢复: {restored}') + return sel_disp, cand_disp, sp2, cp2, msg, *_stats_fields(base_dir) + + def do_swap(base_dir, si, ci, backend, sp, cp, fh, fv, fexpr, fcomp): + if si < 0 or ci < 0 or si >= len(sp) or ci >= len(cp): + return gr.update(), gr.update(), sp, cp, gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), \ + '❌ 请先在两边各点选一张图' + base_dir = clean_path(base_dir) + sel_path = Path(sp[si]) + cand_path = Path(cp[ci]) + kind = sel_path.parent.name # 特写/半身/全身 + try: + # 1-3. 检测 + 按类型裁剪 + VLM caption(失败则不动任何文件) + crop, caption, angle_en = _prep_candidate(cand_path, kind, backend) + # 4. 换出:旧文件移回 未选中(带时间戳防重名) + import time + unused = Path(base_dir) / "未选中" + unused.mkdir(exist_ok=True) + out_name = f"换出_{int(time.time())}_{sel_path.name}" + sel_path.rename(unused / out_name) + old_txt = sel_path.with_suffix(".txt") + if old_txt.exists(): + old_txt.unlink() + # 5. 换入:写新图 + caption(沿用原文件名,保持编号连续) + crop.save(sel_path, quality=95) + sel_path.with_suffix(".txt").write_text(caption, encoding="utf-8") + # 6. 候选原图从未选中移除(已换入) + cand_path.unlink() + except Exception as e: + return gr.update(), gr.update(), sp, cp, gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), \ + f'❌ 交换失败: {e}' + selected, cands = _swap_collect(base_dir) + sel_disp, cand_disp, sp2, cp2 = _reapply_filter(base_dir, fh, fv, fexpr, fcomp, cands) + msg = (f'✅ 已交换:{cand_path.name} → {kind}/{sel_path.name} ' + f'(角度 {angle_en},caption 已重新生成,可到 ③ 打标微调)') + return sel_disp, cand_disp, sp2, cp2, msg, *_stats_fields(base_dir) + + def do_add(base_dir, ci, kind, backend, cp, fh, fv, fexpr, fcomp): + """添加(非交换):候选图按所选类型裁剪+打标,追加为新编号素材;候选保留可继续添加到其他类型""" + if ci < 0 or ci >= len(cp): + return gr.update(), gr.update(), gr.update(), gr.update(), \ + '❌ 请先在右边点选一张候选图', gr.update(), gr.update(), gr.update(), gr.update(), gr.update() + base_dir = clean_path(base_dir) + cand_path = Path(cp[ci]) + try: + crop, caption, angle_en = _prep_candidate(cand_path, kind, backend) + prefix = {"特写": "face", "半身": "half", "全身": "full"}[kind] + kind_dir = Path(base_dir) / kind + kind_dir.mkdir(exist_ok=True) + nums = [] + for p in kind_dir.glob(f"{prefix}_*.jpg"): + parts = p.stem.split("_") + if len(parts) == 2 and parts[1].isdigit(): + nums.append(int(parts[1])) + n = max(nums) + 1 if nums else 1 + dst = kind_dir / f"{prefix}_{n:03d}.jpg" + crop.save(dst, quality=95) + dst.with_suffix(".txt").write_text(caption, encoding="utf-8") + except Exception as e: + return gr.update(), gr.update(), gr.update(), gr.update(), \ + f'❌ 添加失败: {e}', gr.update(), gr.update(), gr.update(), gr.update(), gr.update() + selected, cands = _swap_collect(base_dir) + sel_disp, cand_disp, sp2, cp2 = _reapply_filter(base_dir, fh, fv, fexpr, fcomp, cands) + msg = (f'✅ 已添加:{cand_path.name} → {kind}/{dst.name} ' + f'(角度 {angle_en},caption 已生成)。候选仍保留在未选中,可继续添加到其他类型') + return sel_disp, cand_disp, sp2, cp2, msg, *_stats_fields(base_dir) + + + load_btn.click(load, inputs=base_tb, + outputs=[sel_gal, cand_gal, sel_paths, cand_paths, status_html, h_dist, v_dist, expr_dist, comp_dist, advice_html]) + # 页面加载/刷新时自动从 gui_state 同步 auto 输出目录(①跑完自动带过来) + demo.load(lambda: state_val("auto_out", ""), outputs=base_tb) + # 页面加载时也刷新统计(无需先点选图) + + # 筛选:4 个下拉任一变化 → 过滤已选(输出 画廊+操作列表+状态);清除按钮恢复全部 + for _fd in (f_h, f_v, f_expr, f_comp): + _fd.change(_filter_selected, inputs=[base_tb, f_h, f_v, f_expr, f_comp], + outputs=[sel_gal, sel_paths, filter_info]) + clear_filter.click(_clear_filter, inputs=[base_tb], outputs=[sel_gal, sel_paths, filter_info]) + + sel_gal.select(on_sel, inputs=[base_tb, sel_paths], outputs=[sel_idx, crop_src, crop_file, cap_tb, crop_status, h_dd, v_dd, expr_dd, comp_dd]) + cand_gal.select(on_cand, inputs=[base_tb, cand_paths], outputs=[cand_idx, crop_src, crop_file, cap_tb, crop_status, h_dd, v_dd, expr_dd, comp_dd]) + for _dd in (h_dd, v_dd, expr_dd, comp_dd): + _dd.change(cap_rebuild, inputs=[h_dd, v_dd, expr_dd, comp_dd, cap_tb], outputs=cap_tb) + cap_regen.click(cap_regen_fn, inputs=[crop_file, vlm_backend], outputs=[cap_tb, cap_status]) + cap_save.click(cap_save_fn, inputs=[base_tb, crop_file, cap_tb, comp_dd, sel_paths, cand_paths, f_h, f_v, f_expr, f_comp], + outputs=[sel_gal, cand_gal, sel_paths, cand_paths, crop_src, crop_file, cap_tb, cap_status, + h_dd, v_dd, expr_dd, comp_dd, h_dist, v_dist, expr_dist, comp_dist, advice_html]) + for s in (cr_x0, cr_y0, cr_x1, cr_y1): + s.change(crop_preview, inputs=[crop_file, cr_x0, cr_y0, cr_x1, cr_y1], outputs=crop_prev) + crop_btn.click(crop_apply, + inputs=[base_tb, crop_file, cr_x0, cr_y0, cr_x1, cr_y1, sel_paths, cand_paths, f_h, f_v, f_expr, f_comp], + outputs=[sel_gal, cand_gal, sel_paths, cand_paths, crop_status, h_dist, v_dist, expr_dist, comp_dist, advice_html]) + swap_btn.click(do_swap, inputs=[base_tb, sel_idx, cand_idx, vlm_backend, sel_paths, cand_paths, f_h, f_v, f_expr, f_comp], + outputs=[sel_gal, cand_gal, sel_paths, cand_paths, status_html, h_dist, v_dist, expr_dist, comp_dist, advice_html]) + add_btn.click(do_add, inputs=[base_tb, cand_idx, kind_dd, vlm_backend, cand_paths, f_h, f_v, f_expr, f_comp], + outputs=[sel_gal, cand_gal, sel_paths, cand_paths, status_html, h_dist, v_dist, expr_dist, comp_dist, advice_html]) + cancel_btn.click(do_remove, inputs=[base_tb, sel_idx, sel_paths, f_h, f_v, f_expr, f_comp], + outputs=[sel_gal, cand_gal, sel_paths, cand_paths, status_html, h_dist, v_dist, expr_dist, comp_dist, advice_html]) + prepare_btn.click(_prepare_trainset, inputs=[base_tb], outputs=status_html) + + +# ================= Tab 5: 融合工具 ================= +def build_merge_tab(): + with gr.Tab("🔗 融合(可选)"): + gr.Markdown("**merge_fixed**:原图未修改区 + ComfyUI 修改图修改区 无缝合并(解决 inpainting 全图清晰度损失)") + with gr.Row(): + m_orig = gr.Textbox(label="原图目录", value=state_val("merge_orig", ""), scale=2) + m_mod = gr.Textbox(label="修改图目录(IMG_0463*.png)", value=state_val("merge_mod", ""), scale=2) + m_out = gr.Textbox(label="输出目录", value=state_val("merge_out", ""), scale=2) + with gr.Row(): + m_threshold = gr.Slider(4, 20, value=float(state_clamp("merge_threshold", 8, 4, 20)), step=0.5, label="修改幅度阈值(人物移除diff高、降质diff低;误判时调)") + m_softness = gr.Slider(0.5, 5, value=float(state_clamp("merge_softness", 1.5, 0.5, 5)), step=0.5, label="过渡带宽度(越小修改区越彻底,防残影)") + with gr.Row(): + m_protect = gr.Checkbox(value=bool(state_val("merge_protect", True)), label="保护目标人物面部(强制保留原图像素,零改变)") + m_btn = gr.Button("🔗 开始融合", variant="primary") + m_stats = gr.HTML("就绪") + m_log = gr.Textbox(label="日志", lines=15, interactive=False) + + def do_merge(o_dir, m_dir, out_dir, threshold, softness, protect): + o_dir, m_dir, out_dir = clean_path(o_dir), clean_path(m_dir), clean_path(out_dir) + save_state("merge_orig", o_dir) + save_state("merge_mod", m_dir) + save_state("merge_out", out_dir) + save_state("merge_threshold", threshold) + save_state("merge_softness", softness) + save_state("merge_protect", protect) + if not Path(o_dir).exists() or not Path(m_dir).exists(): + return '❌ 原图或修改图目录不存在', "" + log_txt, _ = run_with_log(mf.process, o_dir, m_dir, out_dir, threshold, softness, protect) + return f'✅ 融合完成', log_txt + + m_btn.click(do_merge, [m_orig, m_mod, m_out, m_threshold, m_softness, m_protect], [m_stats, m_log]) + + +# ================= Tab 6: 训练 ================= +TRAIN_SCRIPT = PROJ_DIR / "config" / "训练脚本.py" +STATUS_FILE = PROJ_DIR / "output" / "train_status.json" +TRAIN_CONFIG = PROJ_DIR / "config" / "train_config.json" +MODELS_DIR = Path(r"D:\AI\sd\models\qwen-edit-2511") +_train_proc = {"proc": None} + + +def _train_cfg(): + """读 train_config.json(训练目录的唯一事实来源),失败返回 {}""" + if TRAIN_CONFIG.exists(): + try: + return json.loads(TRAIN_CONFIG.read_text(encoding="utf-8-sig")) + except Exception: + return {} + return {} + + +def build_train_tab(): + with gr.Tab("④ 训练"): + gr.Markdown( + """**④ 训练**:启动脸部 LoRA 训练(后台隔夜跑)。**三个目录说明:** + +| 目录 | 用途 | 里面放什么 | +|---|---|---| +| **训练集目录** | 训练的素材(必须已打标) | `img_001.jpg` + `img_001.txt`(③ 打标一键整理自动写入,此处自动同步) | +| **输出目录** | 训练产物 | `myface_lora-*.safetensors`(LoRA)+ `sample/`(每 10 epoch 自动样本图) | +| **模型目录** | 训练底模(固定,勿改) | DiT 分片 + VAE + 文本编码器(`D:\\AI\\sd\\models\\qwen-edit-2511`) | + +**点击顺序**:③ 打标 💾保存 → 📦一键整理 → 回到这里确认目录已同步 → 🔄 重新预缓存(整理后必点,否则训旧图)→ ▶️ 启动。""" + ) + _cfg = _train_cfg() + with gr.Row(): + td = gr.Textbox(label="训练集目录", value=_cfg.get("train_dataset", state_val("train_ds", str(TRAIN_DATASET))), scale=2) + od = gr.Textbox(label="输出目录(LoRA + 样本图)", value=_cfg.get("output_dir", state_val("train_out", str(CHECKPOINT_DIR))), scale=2) + gr.Markdown(f"**模型目录(固定)**: `{MODELS_DIR}`(不要改)") + # ── 打标关键字统计(整理训练集之后,用于挑选测试 prompt 关键词)── + with gr.Accordion("📊 打标关键字统计(选词做测试 prompt)", open=True): + kw_stats = gr.HTML('输入训练集目录后自动统计(也可点按钮刷新)') + kw_refresh = gr.Button("🔄 刷新关键字统计", size="sm") + with gr.Row(): + cache_btn = gr.Button("🔄 重新预缓存(整理后必点)") + start_btn = gr.Button("▶️ 启动训练(后台隔夜跑)", variant="primary") + stop_btn = gr.Button("⏹️ 停止训练") + refresh_btn = gr.Button("🔄 刷新状态") + t_status = gr.HTML("未启动") + t_log = gr.Textbox(label="日志(训练/缓存输出尾部)", lines=8, interactive=False) + t_gallery = gr.Gallery(label="训练样本图(每 10 epoch 自动生成)", columns=4, height=350) + + def start_train(train_ds, out_dir): + if _train_proc["proc"] and _train_proc["proc"].poll() is None: + return '⚠️ 训练已在运行', None + train_ds, out_dir = clean_path(train_ds), clean_path(out_dir) + save_state("train_ds", train_ds) + save_state("train_out", out_dir) + train_ds, out_dir = Path(train_ds), Path(out_dir) + if not list(train_ds.glob("img_*.jpg")): + return '❌ 训练集为空(没有 img_*.jpg),先去 ③ 打标整理', None + if not MODELS_DIR.exists(): + return '❌ 模型目录不存在(底模未下载?)', None + # 写训练配置(动态目录) + TRAIN_CONFIG.write_text( + json.dumps({"train_dataset": str(train_ds), "output_dir": str(out_dir)}, + ensure_ascii=False, indent=1), encoding="utf-8") + out_dir.mkdir(parents=True, exist_ok=True) + logf = open(PROJ_DIR / "output" / "train.log", "w", encoding="utf-8") + p = subprocess.Popen( + [sys.executable, str(TRAIN_SCRIPT)], + stdout=logf, stderr=subprocess.STDOUT, cwd=str(PROJ_DIR), creationflags=subprocess.CREATE_NO_WINDOW, + ) + _train_proc["proc"] = p + return ('✅ 训练已启动(后台运行)。训练集: ' + f'{train_ds.name},输出: {out_dir.name}'), None + + def stop_train(): + p = _train_proc.get("proc") + if p and p.poll() is None: + p.terminate() + return '⏹️ 已发送停止请求' + return '没有运行中的训练' + + def refresh(): + samples = sorted(glob.glob(str(SAMPLE_DIR / "*.png")))[-16:] if SAMPLE_DIR.exists() else [] + log_txt = "" + logf = PROJ_DIR / "output" / "train.log" + if logf.exists(): + lines = logf.read_text(encoding="utf-8", errors="replace").strip().splitlines() + log_txt = "\n".join(lines[-8:]) + p = _train_proc.get("proc") + if p and p.poll() is None: + status = '🟢 训练运行中(样本图 ' + str(len(samples)) + ' 张)' + else: + status = '训练未在运行' + return status, log_txt, samples + + def recache(train_ds): + """重新预缓存(VAE latent + TextEncoder)。素材整理后必须重缓存,否则训练用旧缓存。""" + train_ds = clean_path(train_ds) + save_state("train_ds", train_ds) + # 先把目录写进 train_config(run_cache 读 dataset_active.toml,由训练脚本从 config 生成) + cfg = {} + if TRAIN_CONFIG.exists(): + try: + cfg = json.loads(TRAIN_CONFIG.read_text(encoding="utf-8-sig")) + except Exception: + pass + cfg["train_dataset"] = train_ds + TRAIN_CONFIG.write_text(json.dumps(cfg, ensure_ascii=False, indent=1), encoding="utf-8") + # dataset_active.toml 由训练脚本生成;这里手动同步一份保证缓存脚本读的是新目录 + toml_path = PROJ_DIR / "config" / "dataset_active.toml" + toml_path.write_text( + '# 由 ⑤ 训练 Tab 重新预缓存生成\n[general]\nresolution = 1024\ncaption_extension = ".txt"\n' + 'batch_size = 1\nenable_bucket = true\nbucket_no_upscale = false\n\n' + f'[[datasets]]\nimage_directory = "{train_ds.replace(chr(92), "/")}"\nnum_repeats = 1\n', + encoding="utf-8") + cache_script = PROJ_DIR / "output" / "run_cache.py" + r = subprocess.run([sys.executable, str(cache_script)], + capture_output=True, text=True, encoding="utf-8", errors="replace") + tail = "\n".join(((r.stdout or "") + (r.stderr or "")).splitlines()[-6:]) + if r.returncode == 0: + return f'✅ 预缓存完成({train_ds}),可以启动训练', tail + return f'❌ 预缓存失败,见日志', tail + + cache_btn.click(recache, [td], [t_status, t_log]) + start_btn.click(start_train, [td, od], [t_status, t_gallery]) + stop_btn.click(stop_train, outputs=[t_status, t_gallery]) + refresh_btn.click(refresh, outputs=[t_status, t_log, t_gallery]) + # 关键字统计:目录变化或点按钮时刷新 + td.change(lambda d: _keyword_stats(d), inputs=td, outputs=kw_stats) + kw_refresh.click(lambda d: _keyword_stats(d), inputs=td, outputs=kw_stats) + # 自动刷新:每 15 秒更新状态/日志/样本图,不用手动点 + timer = gr.Timer(15) + timer.tick(refresh, outputs=[t_status, t_log, t_gallery]) + + +def build_demo(): + with gr.Blocks(title="脸部 LoRA 控制台", theme=gr.themes.Soft(), + css="""/* 字段行:label 按内容宽,下拉框固定窄,分布小字占剩余空间 */ + .field-row { flex-wrap: wrap !important; align-items: center; } + .field-row .x-label { flex: 0 0 auto !important; width: auto !important; min-width: 0 !important; margin-right: 8px; } + .field-row .fld-dd { flex: 0 0 auto !important; width: 110px !important; min-width: 0 !important; max-width: 110px !important; } + .field-row .gr-html { flex: 1 1 auto !important; min-width: 0 !important; overflow: visible !important; } + .field-stats { white-space: nowrap; } + .field-warn { font-size: 11px; color: #ff6b6b; font-weight: 600; margin-left: 8px; white-space: nowrap; } + /* 筛选栏:一行紧凑排列(label 窄 + 下拉窄 + 计数 + 清除按钮) */ + .filter-row { flex-wrap: wrap !important; align-items: center; gap: 4px !important; } + .filter-row .x-label { flex: 0 0 auto !important; margin-right: 6px !important; font-size: 13px !important; } + .filter-row .fld-dd { flex: 0 0 auto !important; width: 96px !important; min-width: 0 !important; max-width: 96px !important; } + .filter-row .filter-cnt { flex: 1 1 auto !important; min-width: 0 !important; white-space: nowrap; } + /* 全屏预览(slide show)里的操作按钮:fixed 定位在视口右上角空白处(不遮挡画廊/缩略图/全屏小方框) */ + .slide-bar { + position: fixed !important; + top: 70px !important; + right: 24px !important; + z-index: 9999 !important; + display: none; + align-items: center !important; + gap: 8px !important; + background: rgba(20,20,20,0.85) !important; + padding: 8px 12px !important; + border-radius: 10px !important; + box-shadow: 0 2px 10px rgba(0,0,0,0.4) !important; + } + .slide-bar.show { display: inline-flex !important; } + /* ③打标 拖拽替换的内部通道:保留 DOM 供 JS 使用,但视觉隐藏(编辑面板可见) */ + #kw_old_tb, #kw_new_tb, #kw_apply_btn { display: none !important; } + .slide-bar select { + padding: 4px 6px !important; + border-radius: 6px !important; + border: none !important; + font-size: 12px !important; + background: #374151 !important; + color: #f3f4f6 !important; + } + .slide-bar button { + padding: 5px 12px !important; + border-radius: 6px !important; + border: none !important; + font-size: 12px !important; + font-weight: 600 !important; + cursor: pointer !important; + } + .slide-bar .sel-cancel { background: #dc2626 !important; color: #fff !important; } + .slide-bar .cand-add { background: #16a34a !important; color: #fff !important; } + """, + js="""// 刷新/重载时恢复滚动位置(Gradio SPA 默认拉到顶)。 + // ⚠️ Gradio 会把这个 js 当成「函数」执行(new AsyncFunction + (${js})()),所以必须定义函数而非 IIFE! + () => { + const KEY = 'caption_gui_scrollY'; + const save = () => { try { sessionStorage.setItem(KEY, String(window.scrollY)); } catch(e){} }; + const restore = () => { + try { + const y = parseInt(sessionStorage.getItem(KEY) || '0', 10); + if (y > 0) { + window.scrollTo(0, y); + setTimeout(() => window.scrollTo(0, y), 300); // Gradio 渲染完成后补一次 + setTimeout(() => window.scrollTo(0, y), 1000); + } + } catch(e){} + }; + window.addEventListener('beforeunload', save); + window.addEventListener('load', restore); + window.addEventListener('scroll', () => { try { sessionStorage.setItem(KEY, String(window.scrollY)); } catch(e){} }, {passive:true}); + + // ── 日志框 tail-follow:auto 日志更新时自动滚到底(用户上翻历史则暂停跟随)── + const LOG_ID = 'auto_log'; + let logPinned = true; + const findLog = () => document.getElementById(LOG_ID)?.querySelector('textarea'); + const isNearBottom = (el) => el && (el.scrollHeight - el.scrollTop - el.clientHeight) < 60; + // 用户滚动时更新 pinned 状态:滚到底部 → 恢复跟随;上翻 → 暂停 + const attachScroll = () => { + const el = findLog(); + if (!el || el.__logTailAttached) return; + el.__logTailAttached = true; + el.addEventListener('scroll', () => { logPinned = isNearBottom(el); }, { passive: true }); + }; + // 内容变化(Gradio 更新 value)后:若在底部则强制滚到底 + const tailFollow = () => { + const el = findLog(); + if (!el) return; + attachScroll(); + if (logPinned && !isNearBottom(el)) { + el.scrollTop = el.scrollHeight; + } + }; + // Gradio 更新 value 是设置 textarea.value + dispatch,用 interval 兜底(组件懒渲染时也能抓到) + setInterval(() => { attachScroll(); tailFollow(); }, 500); + // 同时监听 textarea 区域变化,立即响应 + const mo = new MutationObserver(tailFollow); + const startObserve = () => { + const el = findLog(); + if (el && !el.__logTailObserved) { + el.__logTailObserved = true; + mo.observe(el, { childList: true, subtree: true, characterData: true }); + } + }; + setInterval(startObserve, 500); + + // ── 全屏预览(slide show)内嵌操作栏:已选预览→取消已选;候选预览→添加选中候选(带类型下拉)── + // 按钮 fixed 定位在视口右上角(空白处,不挡图),append 到 body;显隐跟随对应画廊的预览状态 + const injectSlideButtons = () => { + const mkBar = (cls) => { + let bar = document.querySelector(cls); + if (!bar) { + bar = document.createElement('div'); + bar.className = 'slide-bar ' + cls.replace('.', ''); + document.body.appendChild(bar); + } + return bar; + }; + // 已选画廊:「取消已选」按钮 + const selBar = mkBar('.sel-bar'); + if (!selBar.querySelector('button')) { + const b = document.createElement('button'); + b.className = 'sel-cancel'; + b.textContent = '↩️ 取消已选(移入候选)'; + b.addEventListener('click', () => { + const real = document.getElementById('btn_cancel_sel'); + if (real) real.click(); + }); + selBar.appendChild(b); + } + // 候选画廊:「添加选中候选」+ 类型下拉 + const candBar = mkBar('.cand-bar'); + if (!candBar.querySelector('select')) { + const sel = document.createElement('select'); + ['特写', '半身', '全身'].forEach(k => { + const o = document.createElement('option'); + o.value = k; o.textContent = k; + sel.appendChild(o); + }); + const b = document.createElement('button'); + b.className = 'cand-add'; + b.textContent = '➕ 添加选中候选'; + b.addEventListener('click', () => { + // 同步类型下拉到页面真实 kind_dd(elem_id=cand_kind_dd),再触发添加按钮 + const dd = document.querySelector('#cand_kind_dd select, #cand_kind_dd input'); + if (dd) { + const setter = Object.getOwnPropertyDescriptor( + dd.tagName === 'SELECT' ? window.HTMLSelectElement.prototype : window.HTMLInputElement.prototype, + 'value').set; + setter.call(dd, sel.value); + dd.dispatchEvent(new Event('change', { bubbles: true })); + } + const real = document.getElementById('btn_add_cand'); + if (real) real.click(); + }); + candBar.appendChild(sel); + candBar.appendChild(b); + } + // 显隐:对应画廊预览打开(.preview 存在)→ 显示该操作栏。 + // 两个画廊的 preview 可同时开(Gradio 两个独立组件),操作栏固定在同一位置会重叠—— + // 用「最后点击的画廊」互斥(document click 记录,见下),比时间戳可靠。 + const selG = document.getElementById('sel_gallery'); + const candG = document.getElementById('cand_gallery'); + const selOpen = !!(selG && selG.querySelector('.preview')); + const candOpen = !!(candG && candG.querySelector('.preview')); + if (selOpen && candOpen) { + // 两个都开:显示最后被点击的那个画廊的操作栏 + const showSel = __lastActive === 'sel'; + selBar.classList.toggle('show', showSel); + candBar.classList.toggle('show', !showSel); + } else { + selBar.classList.toggle('show', selOpen); + candBar.classList.toggle('show', candOpen); + } + }; + // 记录用户最后点击的画廊(document 捕获阶段监听,最可靠反映用户意图) + let __lastActive = null; + document.addEventListener('click', (e) => { + const t = e.target; + if (!t || !t.closest) return; + if (t.closest('#sel_gallery')) __lastActive = 'sel'; + else if (t.closest('#cand_gallery')) __lastActive = 'cand'; + }, true); + setInterval(injectSlideButtons, 400); + const mob = new MutationObserver(injectSlideButtons); + const startSlideObserve = () => { + for (const id of ['sel_gallery', 'cand_gallery']) { + const el = document.getElementById(id); + if (el && !el.__slideObserved) { + el.__slideObserved = true; + mob.observe(el, { childList: true, subtree: true }); + } + } + }; + setInterval(startSlideObserve, 400); + + // ===== 打标统计:拖拽/双击 批量替换(只作用于 #label_kw_stats 容器) ===== + function kwInit() { + const statsEl = document.getElementById('label_kw_stats'); + if (!statsEl || statsEl.dataset.kwBound) return; + statsEl.dataset.kwBound = '1'; + // 拖拽开始:记录源词 + statsEl.addEventListener('dragstart', (e) => { + const el = e.target.closest('.kw-item'); + if (!el) return; + e.dataTransfer.setData('text/plain', el.dataset.word); + e.dataTransfer.effectAllowed = 'move'; + }); + // 拖拽悬停:允许放下 + statsEl.addEventListener('dragover', (e) => { + if (!e.target.closest('.kw-item')) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + }); + // 放下:把「目标词」替换成「被拖的词」(例:拖"黑蕾丝抹胸裙"到"黑白抹胸"上 = 所有 caption 中"黑白抹胸"→"黑蕾丝抹胸裙") + statsEl.addEventListener('drop', (e) => { + const tgt = e.target.closest('.kw-item'); + if (!tgt) return; + e.preventDefault(); + const src = e.dataTransfer.getData('text/plain'); + if (!src) return; + kwSetReplace(tgt.dataset.word, src); + }); + // 双击:填入编辑面板(inline 编辑,比 prompt 舒服),用户改完点「应用修改」 + statsEl.addEventListener('dblclick', (e) => { + const el = e.target.closest('.kw-item'); + if (!el) return; + e.preventDefault(); + const old = el.dataset.word; + const oldEl = document.getElementById('kw_old_tb'); + const editEl = document.getElementById('kw_edit_tb'); + if (!oldEl || !editEl) return; + const setVal = (root, v) => { + const ta = root.querySelector('textarea') || root.querySelector('input'); + if (ta) { ta.value = v; ta.dispatchEvent(new Event('input', {bubbles: true})); } + }; + setVal(oldEl, old); + setVal(editEl, old); + const ta = editEl.querySelector('textarea'); + if (ta) ta.focus(); + }); + } + function kwSetReplace(oldWord, newWord) { + if (!oldWord || !newWord || oldWord === newWord) return; + const oldEl = document.getElementById('kw_old_tb'); + const newEl = document.getElementById('kw_new_tb'); + const btn = document.getElementById('kw_apply_btn'); + if (!oldEl || !newEl || !btn) return; + const setVal = (root, v) => { + const ta = root.querySelector('textarea') || root.querySelector('input'); + if (ta) { ta.value = v; ta.dispatchEvent(new Event('input', {bubbles: true})); } + }; + setVal(oldEl, oldWord); + setVal(newEl, newWord); + const bt = btn.querySelector('button') || btn; + bt.click(); + } + setInterval(kwInit, 800); + + // 缩略图点击 → 滚动到下方对应图片卡片 + 金色高亮闪烁 + 聚焦 Caption 框 + // 必须挂到 window:Gradio Blocks js 作用域不暴露内部函数,onclick 需全局函数 + window.scrollToKwCard = function(idx) { + const card = document.getElementById('img_card_' + idx); + if (!card) return; + card.scrollIntoView({ behavior: 'smooth', block: 'center' }); + // 高亮闪烁(2 次) + let n = 0; + const flash = setInterval(() => { + card.style.boxShadow = n % 2 === 0 ? '0 0 0 4px #fbbf24' : ''; + n++; + if (n >= 4) { clearInterval(flash); card.style.boxShadow = ''; } + }, 300); + // 聚焦 Caption 文本框 + const tb = document.getElementById('cap_tb_' + idx); + const ta = tb ? tb.querySelector('textarea') : null; + if (ta) { + setTimeout(() => { ta.focus(); }, 500); + } + }; + return true; + }""") as demo: + gr.Markdown(f"# 🎛️ 脸部 LoRA 一站式控制台 v{VERSION}") + gr.Markdown( + "**主线工作流(按序号一步步来,参数自动传递到下一步)**:\n" + "① 素材自动处理(final/ → 分类裁剪打标筛选) → " + "② 候选换图(调整素材 + 审改 caption + 整理训练集) → " + "④ 训练(重新预缓存 → 启动)\n\n" + "🔗 融合 / 🔍 审核 / 🎯 选图 是可选工具,一般不需要用。" + ) + with gr.Row(): + vlm_backend = gr.CheckboxGroup( + ["ollama", "omlx", "omlx-32b", "sensenova"], + value=["omlx-32b"], + label="VLM 模型组合(勾选参与判定的模型:多选=投票更准,单选=只用一个。" + "ollama=本地8B / omlx=小果30B / omlx-32b=小果qwen2.5-VL-32B-Q8 / sensenova=云端)", + info="打标/描述自动用优先级最高的勾选模型(omlx-32b > omlx > ollama > sensenova);" + "omlx 与 omlx-32b 同属小果一台服务器,同时勾选时自动只保留 omlx-32b 一路") + with gr.Tabs(): + build_auto_tab(vlm_backend) + build_swap_tab(demo, vlm_backend) + # ③ 打标:恢复独立 tab,专用于 LLM 批量整理 tag(拆短句+合并同义词) + build_label_tab(demo, vlm_backend) + build_train_tab() + build_merge_tab() + build_check_tab() + build_pick_tab() + return demo + + +if __name__ == "__main__": + demo = build_demo() + # generator(yield 流式输出)必须启用 queue 才能实时逐帧刷新,否则点击按钮无响应/等全部完成才显示 + demo.queue() + print(f"\n[OK] 控制台启动 v{VERSION}: http://127.0.0.1:7860") + demo.launch(server_name="127.0.0.1", server_port=7860, inbrowser=False, show_error=True, + # Gradio 安全限制:返回给 Gallery/Image 的文件路径必须在 allowed_paths 内, + # 否则报 InvalidPathError(K:\ 素材盘 + 项目 photos/output 都放行) + allowed_paths=[r"K:\AI\training", str(PHOTOS), str(PROJ_DIR / "output")]) diff --git a/tools/face_checker.py b/tools/face_checker.py new file mode 100644 index 0000000..a7bea2c --- /dev/null +++ b/tools/face_checker.py @@ -0,0 +1,1732 @@ +# -*- coding: utf-8 -*- +""" +face_checker.py — LoRA 训练素材自动审核 + 整理工具 +==================================================== +功能: + 1. 扫描照片目录,自动判断每张图是否适合做 LoRA 训练素材 + 2. 检测项:分辨率 / 清晰度 / 人脸数 / 人脸占比 / 正侧脸角度 / 五官遮挡 / 重复图 + 3. 生成可视化 HTML 审核报告(图片墙 + 红黄绿标记 + 原因) + 4. 一键把合格照片整理成训练集(重命名 img_001.jpg + EXIF 修正) + +用法: + python face_checker.py check <照片目录> [--out 报告输出目录] + python face_checker.py prepare <照片目录> --out <训练集目录> + python face_checker.py batch <照片目录> --out <训练集目录> + +依赖:opencv-python, pillow, numpy(musubi-tuner 虚拟环境已具备) +""" + +import argparse +import json +import math +import os +import shutil +import sys +import threading +import traceback +from concurrent.futures import ThreadPoolExecutor, as_completed +from pathlib import Path + +# 模块级编码兜底:无论 CLI 还是被 GUI import,stdout/stderr 都按 UTF-8 处理,避免 GBK 控制台报错 +for _stream in (sys.stdout, sys.stderr): + try: + _stream.reconfigure(encoding="utf-8", errors="replace") + except Exception: + pass + +import cv2 +import numpy as np +from PIL import Image, ImageOps, ImageDraw + +# ---------- 常量 ---------- +SCRIPT_DIR = Path(__file__).resolve().parent +# OpenCV DNN 的 ONNX 加载不支持非 ASCII 路径(C++ 层窄字符 IO), +# 因此模型优先从 ASCII 路径加载,找不到才回退到脚本目录 +_ASCII_CANDIDATES = [ + Path(r"D:\AI\sd\musubi-tuner\models_yunet\face_detection_yunet.onnx"), + Path(r"D:\AI\sd\musubi-tuner\face_detection_yunet.onnx"), +] +YUNET_PATH = next((p for p in _ASCII_CANDIDATES if p.exists()), SCRIPT_DIR / "models" / "face_detection_yunet.onnx") + +MIN_RESOLUTION = 1024 # 合格最小短边分辨率 +BLUR_THRESHOLD = 100.0 # Laplacian 方差低于此值判模糊(经验值,可调) +MIN_FACE_RATIO = 0.05 # 人脸框面积 / 图面积,低于此值判"人脸太小" +DUPLICATE_THRESHOLD = 5 # pHash 汉明距离低于此值判重复 +SIDE_ANGLE_THRESHOLD = 0.35 # 侧脸判定阈值(landmark 几何比) +IMG_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".bmp"} + +STATUS = {"PASS": "合格", "WARN": "警告", "FAIL": "不合格"} + + +# ---------- 人脸检测 ---------- +def _ensure_ascii_model_path(path): + """OpenCV ONNX importer 在 Windows 上读不了非 ASCII 路径(中文目录)。 + 若路径含非 ASCII 字符,自动拷贝到 %TEMP% 的 ASCII 路径并返回该路径。""" + try: + path.encode("ascii") + return path # 纯 ASCII,直接用 + except UnicodeEncodeError: + pass + import shutil + import tempfile + src = Path(path) + dst = Path(tempfile.gettempdir()) / f"yunet_{hash(str(src)) & 0xffffffff}.onnx" + if not dst.exists(): + shutil.copy(src, dst) + return str(dst) + + +class FaceDetector: + """基于 OpenCV YuNet 的人脸检测 + 5 点 landmark""" + + def __init__(self, model_path=YUNET_PATH): + if not Path(model_path).exists(): + raise FileNotFoundError( + f"人脸检测模型不存在: {model_path}\n请下载 face_detection_yunet.onnx 放到 tools/models/ 目录" + ) + # Windows 上 OpenCV ONNX importer 读不了含中文/非 ASCII 的路径 → 自动拷贝到 %TEMP% ASCII 路径 + model_path = _ensure_ascii_model_path(str(model_path)) + self.detector = cv2.FaceDetectorYN_create(model_path, "", (320, 320), 0.6, 0.3, 5000) + + def detect(self, img_bgr): + """返回人脸列表,每项 = [x, y, w, h, landmarks(10 个浮点: 右眼x,y 左眼x,y 鼻x,y 右嘴x,y 左嘴x,y), score]""" + h, w = img_bgr.shape[:2] + self.detector.setInputSize((w, h)) + _, faces = self.detector.detect(img_bgr) + if faces is None: + return [] + return faces.tolist() + + +def load_image(path): + """读取图片并修正 EXIF 方向,返回 (RGB ndarray, 原图信息)""" + img = Image.open(path) + img = ImageOps.exif_transpose(img) + return img + + +def exif_orientation(path): + try: + with Image.open(path) as im: + return im.getexif().get(274, 1) + except Exception: + return 1 + + +# ---------- 检测项 ---------- +def check_resolution(img): + w, h = img.size + short = min(w, h) + if short >= MIN_RESOLUTION: + return STATUS["PASS"], f"{w}×{h} ✓" + return STATUS["FAIL"], f"{w}×{h},短边 {short}px < {MIN_RESOLUTION}px(微信压缩图常见)" + + +def check_sharpness(img, face_box=None): + """清晰度检测:优先评估人脸区域(LoRA 选图关键),没人脸才看全图""" + gray = np.array(img.convert("L")) + if gray.size == 0: + return STATUS["FAIL"], "图片为空" + + if face_box is not None: + x, y, w, h = [int(v) for v in face_box] + hh, ww = gray.shape + # 放大 2 倍评估区域,覆盖整张脸 + 周围 + x0, y0 = max(0, x - w), max(0, y - h) + x1, y1 = min(ww, x + 2 * w), min(hh, y + 2 * h) + if (x1 - x0) > 40 and (y1 - y0) > 40: + gray = gray[y0:y1, x0:x1] + + # 缩小后再算 Laplacian,避免大图噪声干扰 + scale = 1024.0 / max(gray.shape) + if scale < 1.0: + gray = cv2.resize(gray, None, fx=scale, fy=scale, interpolation=cv2.INTER_AREA) + lap = cv2.Laplacian(gray, cv2.CV_64F).var() + if lap < BLUR_THRESHOLD * 0.5: + return STATUS["FAIL"], f"模糊(清晰度 {lap:.0f},阈值 {BLUR_THRESHOLD})" + if lap < BLUR_THRESHOLD: + return STATUS["WARN"], f"偏模糊(清晰度 {lap:.0f},建议 ≥{BLUR_THRESHOLD})" + return STATUS["PASS"], f"清晰({lap:.0f})" + + +def check_face(img, detector): + rgb = np.array(img.convert("RGB")) + bgr = cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR) + faces = detector.detect(bgr) + if not faces: + return STATUS["FAIL"], "未检测到人脸", None + + if len(faces) > 1: + return STATUS["WARN"], f"检测到 {len(faces)} 张脸(LoRA 训练需要单人照)", faces + + f = faces[0] + x, y, w, h, lms, score = f[0], f[1], f[2], f[3], f[4:14], f[14] + img_h, img_w = rgb.shape[:2] + face_area_ratio = (w * h) / (img_w * img_h) + + reasons = [] + # 人脸占比:按构图类型给差异化提示(半身/全身照脸小是正常的,不判警告;只作信息) + if face_area_ratio < 0.005: + reasons.append(f"人脸占比 {face_area_ratio*100:.2f}%(过小,面部信息极少,仅适合作构图/全身补充)") + elif face_area_ratio < 0.02: + reasons.append(f"人脸占比 {face_area_ratio*100:.1f}%(全身照属正常,脸部需清晰可辨)") + elif face_area_ratio < 0.05: + reasons.append(f"人脸占比 {face_area_ratio*100:.1f}%(半身照,脸部偏小但可用)") + else: + reasons.append(f"人脸占比 {face_area_ratio*100:.1f}%(特写,理想)") + status = STATUS["PASS"] + + # 角度判断:双眼 x 距离 vs 双眼 y 偏差;鼻尖是否偏离双眼中心 + re_x, re_y, le_x, le_y = lms[0], lms[1], lms[2], lms[3] + nose_x, nose_y = lms[4], lms[5] + eye_dx = abs(re_x - le_x) + eye_dy = abs(re_y - le_y) + eye_center_x = (re_x + le_x) / 2 + nose_offset = abs(nose_x - eye_center_x) / max(eye_dx, 1e-6) + + if eye_dx < 1e-3 or nose_offset > 1.8: + angle_note = "纯侧面/低头(特征不全)" + if status == STATUS["PASS"]: + status = STATUS["WARN"] + reasons.append(f"角度:{angle_note}") + elif nose_offset > SIDE_ANGLE_THRESHOLD: + angle_note = f"较大侧脸(鼻偏移 {nose_offset:.2f})" + reasons.append(f"角度:{angle_note}(可用,但正脸更佳)") + else: + reasons.append(f"角度:正脸/微侧(鼻偏移 {nose_offset:.2f})") + + # 遮挡粗判:landmark 是否全部落在人脸框内 + lm_pts = [(lms[0], lms[1]), (lms[2], lms[3]), (lms[4], lms[5]), (lms[6], lms[7]), (lms[8], lms[9])] + margin = 0.15 * w + out_count = sum(1 for px, py in lm_pts if not (x - margin <= px <= x + w + margin and y - margin <= py <= y + h + margin)) + if out_count > 0: + status = STATUS["WARN"] + reasons.append(f"五官关键点 {out_count}/5 偏离(疑似口罩/墨镜/刘海遮挡)") + + reasons.append(f"人脸置信度 {score:.2f}") + return status, ";".join(reasons), faces + + +def phash(img, hash_size=16): + """感知哈希,用于重复检测""" + small = img.convert("L").resize((hash_size, hash_size), Image.LANCZOS) + arr = np.asarray(small, dtype=np.float32) + med = np.median(arr) + return (arr > med).astype(np.uint8).flatten() + + +def hamming(a, b): + return int(np.count_nonzero(a != b)) + + +# ---------- 主流程 ---------- +DETECT_MAX_SIDE = 1600 # 保留常量(后续可能用);当前检测用原图 +THUMB_DIR_NAME = ".thumbs" +_thread_local = threading.local() + + +def _get_thread_detector(): + """每个线程独立的 FaceDetector(YuNet 实例非线程安全,共享会误检/串行化)""" + if not hasattr(_thread_local, "det"): + _thread_local.det = FaceDetector() + return _thread_local.det + + +def _nms(faces, iou_threshold=0.5): + """按 score 降序,合并 IoU 重叠的人脸框""" + if not faces: + return [] + faces = sorted(faces, key=lambda f: -f[14]) + keep = [] + for f in faces: + x0, y0, w0, h0 = f[0], f[1], f[2], f[3] + dup = False + for k in keep: + x1, y1, w1, h1 = k[0], k[1], k[2], k[3] + ix = max(0, min(x0 + w0, x1 + w1) - max(x0, x1)) + iy = max(0, min(y0 + h0, y1 + h1) - max(y0, y1)) + inter = ix * iy + union = w0 * h0 + w1 * h1 - inter + if union > 0 and inter / union > iou_threshold: + dup = True + break + if not dup: + keep.append(f) + return keep + + +def _detect_faces_fast(img_rgb, detector, score_threshold=0.6): + """ + 快速人脸检测(原图检测,保持质量;速度靠外层多线程并行)。 + 用 NMS 合并重叠框(合影时多脸不误合并)。 + 过滤:人脸中心 y 超过画面 85% 的大框基本是误检(腿/物体当脸)。 + img_rgb: RGB ndarray;返回 faces(原图坐标)。 + """ + h, w = img_rgb.shape[:2] + bgr = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2BGR) + faces = detector.detect(bgr) + if not faces: + return [] + faces = _nms([f for f in faces if f[14] >= score_threshold]) + # 位置合理性过滤:人脸中心 y 在画面最底部 15% 的大框视为误检 + h_img = h + valid = [f for f in faces if (f[1] + f[3] / 2) / h_img <= 0.85] + return valid if valid else faces # 全部被过滤则返回原始(防误杀特殊构图) + + +def _analyze_one(p, detector=None): + """ + 单张图快速分析: + - 完整解码(保持画质)+ LANCZOS 缩略到 ≤1600 再分析 + - 缩略图检测人脸(NMS+阈值) + - 清晰度在人脸区域(缩略图尺度,阈值 60) + 返回结果 dict(供 analyze_images 聚合)。 + """ + det = _get_thread_detector() if detector is None else detector + img = load_image(p) # 完整解码 + EXIF 修正 + w0, h0 = img.size + small = img.copy() + small.thumbnail((1600, 1600), Image.LANCZOS) + rgb = np.array(small.convert("RGB")) + + # GUI 缩略图(供 gradio 快速显示,避免全尺寸加载) + try: + thumb_dir = Path(p).parent / THUMB_DIR_NAME + thumb_dir.mkdir(exist_ok=True) + thumb = small.copy() + thumb.thumbnail((400, 400), Image.LANCZOS) + thumb.save(thumb_dir / f"{Path(p).stem}.jpg", quality=85) + except Exception: + pass + + # 分辨率检查(原始尺寸) + short = min(w0, h0) + if short >= MIN_RESOLUTION: + r_res = (STATUS["PASS"], f"{w0}×{h0} ✓") + else: + r_res = (STATUS["FAIL"], f"{w0}×{h0},短边 {short}px < {MIN_RESOLUTION}px(微信压缩图常见)") + + # 人脸检测(缩略图尺度),坐标映射回原图尺寸(含 landmarks) + faces = _detect_faces_fast(rgb, det) + if faces: + sx = w0 / rgb.shape[1] + sy = h0 / rgb.shape[0] + mapped = [] + for f in faces: + nf = [f[0]*sx, f[1]*sy, f[2]*sx, f[3]*sy] + lms = list(f[4:14]) # 5 点 landmark (x,y)*5 + for k in range(0, 10, 2): + lms[k] *= sx + lms[k+1] *= sy + mapped.append(nf + lms + [f[14]]) + faces = mapped + face_box = faces[0][0:4] if faces else None + + # 面部/整体清晰度(缩略图尺度) + lap_face = _lap_of(Image.fromarray(rgb), face_box) if face_box else 0.0 + lap_all = _lap_of(Image.fromarray(rgb), None) + if face_box is not None and lap_face >= 60: + r_sharp = (STATUS["PASS"], f"清晰({lap_face:.0f})") + elif face_box is not None: + r_sharp = (STATUS["FAIL"], f"面部模糊(清晰度 {lap_face:.0f},阈值 60)") + else: + r_sharp = (STATUS["WARN"], f"无人脸,全图清晰度 {lap_all:.0f}") + + r_face, face_note, _ = check_face_from_faces(Image.fromarray(rgb), faces) + + all_st = [r_res[0], r_sharp[0], r_face] + if STATUS["FAIL"] in all_st: + status = STATUS["FAIL"] + elif STATUS["WARN"] in all_st: + status = STATUS["WARN"] + else: + status = STATUS["PASS"] + + return { + "file": p.name, "path": str(p), "size": [w0, h0], + "bytes": p.stat().st_size, "status": status, + "reasons": [f"尺寸:{r_res[1]}", f"清晰度:{r_sharp[1]}", f"人脸:{face_note}"], + "faces": faces, "phash": phash(Image.fromarray(rgb)).tolist(), + "exif_orientation": exif_orientation(p), + } + + +def analyze_images(img_dir, detector, verbose=True): + """分析目录下所有图片(draft 快解码 + 缩略图检测),返回结果列表""" + img_dir = Path(img_dir) + images = sorted( + [p for p in img_dir.iterdir() if p.suffix.lower() in IMG_EXTS and not p.name.startswith(".")] + ) + if not images: + print(f"[WARN] 目录 {img_dir} 中没有图片") + return [] + + results = [] + for p in images: + r = _analyze_one(p, detector) + results.append(r) + if verbose: + flag = {STATUS["PASS"]: "[OK]", STATUS["WARN"]: "[WARN]", STATUS["FAIL"]: "[FAIL]"}[r["status"]] + print(f"{flag} {r['file']} [{r['size'][0]}x{r['size'][1]}] {r['status']}") + + results.sort(key=lambda r: r["file"]) + + # 重复检测(两两 pHash 距离) + for i in range(len(results)): + for j in range(i + 1, len(results)): + a, b = results[i], results[j] + if a["phash"] is None or b["phash"] is None: + continue + d = hamming(np.array(a["phash"]), np.array(b["phash"])) + if d < DUPLICATE_THRESHOLD: + note = f"与 {b['file']} 高度相似(可能重复)" + if note not in a["reasons"]: + a["reasons"].append(note) + if a["status"] == STATUS["PASS"]: + a["status"] = STATUS["WARN"] + note = f"与 {a['file']} 高度相似(可能重复)" + if note not in b["reasons"]: + b["reasons"].append(note) + if b["status"] == STATUS["PASS"]: + b["status"] = STATUS["WARN"] + return results + + +def check_face_from_faces(img, faces): + """ + 基于已检测的 faces 做判断(避免重复检测)。 + 返回 (status, note, faces)。供 _analyze_one / 其他模块复用。 + """ + if not faces: + return STATUS["FAIL"], "未检测到人脸", None + if len(faces) > 1: + return STATUS["WARN"], f"检测到 {len(faces)} 张脸(LoRA 训练需要单人照)", faces + f = faces[0] + x, y, w, h, lms, score = f[0], f[1], f[2], f[3], f[4:14], f[14] + rgb = np.array(img.convert("RGB")) + img_h, img_w = rgb.shape[:2] + face_area_ratio = (w * h) / (img_w * img_h) + + reasons = [] + if face_area_ratio < 0.005: + reasons.append(f"人脸占比 {face_area_ratio*100:.2f}%(过小,面部信息极少,仅适合作构图/全身补充)") + elif face_area_ratio < 0.02: + reasons.append(f"人脸占比 {face_area_ratio*100:.1f}%(全身照属正常,脸部需清晰可辨)") + elif face_area_ratio < 0.05: + reasons.append(f"人脸占比 {face_area_ratio*100:.1f}%(半身照,脸部偏小但可用)") + else: + reasons.append(f"人脸占比 {face_area_ratio*100:.1f}%(特写,理想)") + status = STATUS["PASS"] + + re_x, re_y, le_x, le_y = lms[0], lms[1], lms[2], lms[3] + nose_x, nose_y = lms[4], lms[5] + eye_dx = abs(re_x - le_x) + eye_center_x = (re_x + le_x) / 2 + nose_offset = abs(nose_x - eye_center_x) / max(eye_dx, 1e-6) + + if eye_dx < 1e-3 or nose_offset > 1.8: + if status == STATUS["PASS"]: + status = STATUS["WARN"] + reasons.append(f"角度:纯侧面/低头(特征不全)") + elif nose_offset > SIDE_ANGLE_THRESHOLD: + reasons.append(f"角度:较大侧脸(鼻偏移 {nose_offset:.2f})(可用,但正脸更佳)") + else: + reasons.append(f"角度:正脸/微侧(鼻偏移 {nose_offset:.2f})") + + lm_pts = [(lms[0], lms[1]), (lms[2], lms[3]), (lms[4], lms[5]), (lms[6], lms[7]), (lms[8], lms[9])] + margin = 0.15 * w + out_count = sum(1 for px, py in lm_pts if not (x - margin <= px <= x + w + margin and y - margin <= py <= y + h + margin)) + if out_count > 0: + status = STATUS["WARN"] + reasons.append(f"五官关键点 {out_count}/5 偏离(疑似口罩/墨镜/刘海遮挡)") + + reasons.append(f"人脸置信度 {score:.2f}") + return status, ";".join(reasons), faces + + +def draw_annotation(img, faces, path_out): + """在人脸框 + landmark 上画标注,保存预览图""" + draw = ImageDraw.Draw(img) + for f in faces or []: + x, y, w, h = f[0], f[1], f[2], f[3] + lms = f[4:14] + draw.rectangle([x, y, x + w, y + h], outline=(0, 255, 0), width=3) + for k in range(0, 10, 2): + cx, cy = lms[k], lms[k + 1] + r = 4 + draw.ellipse([cx - r, cy - r, cx + r, cy + r], fill=(255, 0, 0)) + img.save(path_out) + + +# ---------- 智能选图 ---------- +def face_meta(r): + """提取单张图的构图/角度特征,用于多样性选图""" + meta = {"ratio": 0.0, "angle": 0.0, "n_faces": 0} + if r["faces"] and r["size"][0] > 0: + f = r["faces"][0] + w, h = r["size"] + meta["ratio"] = (f[2] * f[3]) / (w * h) + meta["n_faces"] = len(r["faces"]) + lms = f[4:14] + re_x, re_y, le_x, le_y, nose_x = lms[0], lms[1], lms[2], lms[3], lms[4] + eye_center = (re_x + le_x) / 2 + eye_dx = abs(re_x - le_x) + 1e-6 + meta["angle"] = abs(nose_x - eye_center) / eye_dx + return meta + + +def compose_label(ratio): + """按人脸框占比分类构图(YuNet 框的是脸部,特写照脸框占比约 8-20%)""" + if ratio >= 0.08: + return "特写" + if ratio >= 0.02: + return "半身" + if ratio >= 0.005: + return "全身" + return "人很小" + + +def pick_diverse(results, count=20): + """ + 从审核结果中贪心挑选多样化的 count 张。 + 策略:farthest-point sampling —— 每步选"与已选集合在 视觉/构图/角度 上差异最大"的图。 + 效果:自动覆盖 多角度 + 多发型/妆容(视觉差异)+ 多构图(特写/半身/全身)。 + """ + ok = [r for r in results if r["status"] != STATUS["FAIL"] and r["phash"] is not None] + if not ok: + return [] + + phs = [np.array(r["phash"], dtype=np.uint8) for r in ok] + metas = [face_meta(r) for r in ok] + quality = [0 if r["status"] == STATUS["PASS"] else 1 for r in ok] + + def dist(i, j): + hd = hamming(phs[i], phs[j]) + rd = abs(metas[i]["ratio"] - metas[j]["ratio"]) * 100 # 构图差异(特写 vs 全身) + ad = abs(metas[i]["angle"] - metas[j]["angle"]) * 6 # 角度差异 + return hd + rd + ad + + n = len(ok) + order = sorted(range(n), key=lambda i: (quality[i], -(metas[i]["ratio"] > 0.05))) + selected = [order[0]] + while len(selected) < min(count, n): + best_i, best_d = None, -1 + for i in range(n): + if i in selected: + continue + d = min(dist(i, s) for s in selected) + d -= 5 if quality[i] else 0 # WARN 图轻微降权,合格图优先 + if d > best_d: + best_d, best_i = d, i + if best_i is None: + break + selected.append(best_i) + + picked = [ok[i] for i in selected] + # 标注构图标签 + for r in picked: + r["compose"] = compose_label(face_meta(r)["ratio"]) + return picked + + +def build_pick_html(picked, out_path, count): + cards = [] + for r in picked: + compose = r.get("compose", "") + reasons_html = "
".join(f"· {x}" for x in r["reasons"][:2]) + size = f"{r['size'][0]}×{r['size'][1]}" if r["size"][0] else "?" + cards.append(f""" +
+
+
+
推荐 + {compose}{r['file']}
+
{size} · {r['bytes']/1024:.0f}KB
+
{reasons_html}
+
+
""") + html = f""" + +LoRA 智能选图推荐 + +

LoRA 智能选图推荐({len(picked)}/{count})

+
按视觉差异 + 构图 + 角度 自动挑出的多样化组合,覆盖多发型/多妆容/多角度/多年龄段。可直接用这些照片训练。
+
{''.join(cards)}
+""" + Path(out_path).write_text(html, encoding="utf-8") + return out_path + + +def crop_single(img, faces, margin_scale=1.8, min_side=1024): + """ + 合影处理:按最大人脸裁切单人区域(正方形,人脸居中放大 margin_scale 倍)。 + 返回 (裁切后的 PIL 图, 说明文本)。 + """ + rgb = np.array(img.convert("RGB")) + h, w = rgb.shape[:2] + if not faces: + return None, "未检测到人脸,无法裁切" + # 取面积最大的人脸(假设是主体) + f = max(faces, key=lambda x: x[2] * x[3]) + fx, fy, fw, fh = int(f[0]), int(f[1]), int(f[2]), int(f[3]) + cx, cy = fx + fw / 2.0, fy + fh / 2.0 + side = int(max(fw, fh) * margin_scale) + # 居中裁切,越界时对齐边界 + x0 = max(0, int(cx - side / 2)) + y0 = max(0, int(cy - side / 2)) + x0 = min(x0, max(0, w - side)) + y0 = min(y0, max(0, h - side)) + x1, y1 = min(w, x0 + side), min(h, y0 + side) + crop = rgb[y0:y1, x0:x1] + out = Image.fromarray(crop) + short = min(out.size) + note = f"裁切 {out.size[0]}×{out.size[1]}(脸框 {fw}×{fh})" + if short < min_side: + note += f";⚠️ 短边 {short}px < {min_side}px,裁后分辨率不足,建议放弃" + else: + note += f";短边 {short}px ✓" + return out, note + + +def crop_dir(img_dir, out_dir, detector, margin_scale=1.8, min_side=1024): + """批量裁切:photos -> croped 目录""" + img_dir = Path(img_dir) + out_dir = Path(out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + images = sorted([p for p in img_dir.iterdir() if p.suffix.lower() in IMG_EXTS and not p.name.startswith(".")]) + done, skipped = 0, [] + for p in images: + try: + img = load_image(p) + rgb = np.array(img.convert("RGB")) + bgr = cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR) + faces = detector.detect(bgr) + if len(faces) > 1: + print(f"[多人 {len(faces)}] {p.name} -> 裁切最大人脸") + crop, note = crop_single(img, faces, margin_scale, min_side) + if crop is None: + skipped.append(f"{p.name}({note})") + continue + crop.save(out_dir / p.name, quality=95) + done += 1 + print(f" [OK] {p.name}: {note}") + except Exception as e: + skipped.append(f"{p.name}({e})") + print(f"\n裁切完成: {done} 张 -> {out_dir}") + if skipped: + print(f"跳过 {len(skipped)} 张: {skipped}") + return done + + +# ---------- 人体检测(OpenCV HOG,零下载) ---------- +class PersonDetector: + """基于 OpenCV HOG 的人体检测(用于全身照识别/裁剪)""" + + def __init__(self): + self.hog = cv2.HOGDescriptor() + self.hog.setSVMDetector(cv2.HOGDescriptor_getDefaultPeopleDetector()) + + def detect(self, img_bgr, max_side=1600): + """返回人体框列表 [[x,y,w,h],...];大图先缩小加速""" + h, w = img_bgr.shape[:2] + scale = 1.0 + if max(h, w) > max_side: + scale = max_side / max(h, w) + img_bgr = cv2.resize(img_bgr, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_AREA) + boxes, _ = self.hog.detectMultiScale(img_bgr, winStride=(8, 8), padding=(8, 8), scale=1.05) + if len(boxes) == 0: + return [] + boxes = boxes.astype(float).tolist() + if scale != 1.0: + boxes = [[x / scale, y / scale, bw / scale, bh / scale] for x, y, bw, bh in boxes] + return boxes + + +def crop_centered_halfbody(img, face): + """ + 半身水平居中(保留原图高度,按人脸水平中心裁掉背景多余侧): + 修复"人偏左/偏右,另一半全是背景"的构图。与 crop_fullbody 的水平居中思路一致, + 但不改高度(半身不看脚部),只把人物移到画面水平中心。 + """ + rgb = np.array(img.convert("RGB")) + h, w = rgb.shape[:2] + fx, fy, fw, fh = [int(v) for v in face[:4]] + face_cx = fx + fw / 2 + cx_ratio = face_cx / w + # 只在明显偏位(中心偏离画面中心 >10%)时裁剪,否则原样返回 + if abs(cx_ratio - 0.5) <= 0.10: + return img, f"半身保留 {w}×{h}(已居中,无需裁)" + # 目标宽度:以人物为中心取 高度×0.9(半身自然比例),不超原宽 + body_w = min(w, int(h * 0.9)) + x0 = max(0, int(face_cx - body_w / 2)) + x1 = min(w, x0 + body_w) + x0 = max(0, x1 - body_w) + crop = rgb[0:h, x0:x1] + out = Image.fromarray(crop) + note = f"半身居中 {out.size[0]}×{out.size[1]}(人脸原在 {cx_ratio*100:.0f}% 处,裁背景侧)" + return out, note + + +def crop_fullbody(img, face): + """ + 全身构图(保留完整人物,不裁上下): + - 垂直:保留原图完整高度(手举过头顶/脚部都不被截) + - 水平:把人物居中(裁剪背景多余侧,人物移到画面中心) + - 竖构图(h*0.75 >= w)时若人脸明显偏位(>10%),同样收窄宽度居中(原逻辑此时不裁导致人偏一边) + 不强行裁到固定宽高比(那是手臂/脚被截的根源)。 + """ + rgb = np.array(img.convert("RGB")) + h, w = rgb.shape[:2] + fx, fy, fw, fh = [int(v) for v in face[:4]] + face_cx = fx + fw / 2 + cx_ratio = face_cx / w + body_w = min(w, int(h * 0.75)) + # 竖构图不触发裁剪(body_w==w)但人脸明显偏位 → 收窄到 h*0.6 以便居中 + if body_w >= w and abs(cx_ratio - 0.5) > 0.10: + body_w = min(w, int(h * 0.6)) + x0 = max(0, int(face_cx - body_w / 2)) + x1 = min(w, x0 + body_w) + x0 = max(0, x1 - body_w) + crop = rgb[0:h, x0:x1] # 高度完整保留,只裁水平 + out = Image.fromarray(crop) + note = f"全身构图 {out.size[0]}×{out.size[1]}(人物居中,高度完整)" + return out, note + + +def crop_face_portrait(img, face, margin=2.5): + """ + 脸部特写裁剪:人脸居中,边长 = 脸短边 × margin。 + margin 2.0 保留完整发型(发际线/刘海/鬓角不被裁掉)。 + 可从任意构图(半身/全身)裁出脸部特写素材。 + """ + rgb = np.array(img.convert("RGB")) + fx, fy, fw, fh = [int(v) for v in face[:4]] + # 脸朝向:鼻尖相对脸框中心偏移 → 侧脸时后脑勺在鼻的反方向 + cx = fx + fw / 2 + nose_offset = (face[8] - cx) / fw if len(face) >= 9 else 0.0 + if abs(nose_offset) > 0.30: + # 明显侧转:裁框中心向后脑方向平移 0.35×脸宽 + back_dir = -1 if nose_offset > 0 else 1 + cx = cx + back_dir * 0.35 * fw + side = int(max(fw, fh) * margin) + # 向上偏:顶部从 脸框上缘 - 1.2×fh(完整保留头顶/发饰/蝴蝶结) + top = max(0, int(fy - 1.2 * fh)) + bottom = min(rgb.shape[0], top + side) + if bottom - top < side: # 到底部贴边了,向上补足 + top = max(0, bottom - side) + x0 = max(0, int(cx - side / 2)) + x1 = min(rgb.shape[1], x0 + side) + x0 = max(0, x1 - side) + crop = rgb[top:bottom, x0:x1] + out = Image.fromarray(crop) + note = f"脸部特写 {out.size[0]}×{out.size[1]}(脸短边 {min(fw,fh):.0f}px,保留发型)" + return out, note + + +def face_sharpness(img, face): + """ + 人脸清晰度(Laplacian):紧贴脸框核心 70% 区域,避免背景稀释。 + 真实照片的雀斑/眉毛清晰时应有高值。 + """ + rgb = np.array(img.convert("RGB")) + h, w = rgb.shape[:2] + fx, fy, fw, fh = [int(v) for v in face[:4]] + cx, cy = fx + fw / 2, fy + fh / 2 + cw, ch = fw * 0.7, fh * 0.7 + x0 = max(0, int(cx - cw / 2)); y0 = max(0, int(cy - ch / 2)) + x1 = min(w, int(cx + cw / 2)); y1 = min(h, int(cy + ch / 2)) + if (x1 - x0) < 30 or (y1 - y0) < 30: + return 0.0 + gray = np.array(Image.fromarray(rgb[y0:y1, x0:x1]).convert("L")) + return float(cv2.Laplacian(gray, cv2.CV_64F).var()) + + +def face_angle(face): + """ + 人脸角度:用 landmarks 判断正脸/半侧面/侧面(ComfyUI/A1111 社区标准措辞)。 + nose_offset = 鼻尖偏离双眼中心的距离(相对眼距),实测标定。 + 返回 (中文标签, 英文标签)。 + """ + lms = face[4:14] + if len(lms) < 10: + return "正脸", "front view" + re_x, re_y, le_x, le_y, nose_x = lms[0], lms[1], lms[2], lms[3], lms[4] + eye_dx = abs(re_x - le_x) + eye_center = (re_x + le_x) / 2 + if eye_dx < 1e-3: + return "正脸", "front view" + nose_offset = abs(nose_x - eye_center) / eye_dx + if nose_offset > 0.6: + return "侧面", "side view" + if nose_offset > 0.15: + return "半侧面", "three-quarter view" + return "正脸", "front view" + + +# ---------- auto 自动流水线 ---------- +# 标签模板:只标角度+构图,不含姿势/服装/背景硬编码(避免雷同;细节由 GUI 打标或 VLM 补充)。 +# ⚠️ 特写模板不再写死 neutral expression——表情由 VLM 描述提供,否则会生成"neutral expression, 大笑"矛盾标签。 +AUTO_TEMPLATES = { + "特写": "photorealistic portrait, soft natural lighting", + "半身": "half body shot, natural lighting", + "全身": "full body shot, natural lighting", +} + +# 本地 Ollama 配置(qwen3-vl 视觉模型,本地稳定,不依赖小果磁盘) +OLLAMA_URL = "http://localhost:11434/api/chat" +OLLAMA_MODEL = "huihui_ai/qwen3-vl-abliterated:8b" +# 小果 oMLX 配置(串行调用!多个可用模型) +OMLX_URL = "http://192.168.1.122:18003/v1/chat/completions" +OMLX_MODEL = "Qwen3-VL-30B-A3B-Thinking-4bit" +OMLX_MODEL_32B = "qwen2.5-VL-32B-abliterated-MLX-Q8" + +SENSENOVA_URL = "https://token.sensenova.cn/v1/chat/completions" +SENSENOVA_KEY = "sk-aRNj3UwKSLPsDfh15QNTPwbHxahblfaO" +SENSENOVA_MODEL = "sensenova-6.7-flash-lite" + +# 后端模型映射:checkbox 选项名 → (来源, 模型) +BACKEND_MODELS = { + "ollama": ("ollama", OLLAMA_MODEL), + "omlx": ("omlx", OMLX_MODEL), + "omlx-32b": ("omlx", OMLX_MODEL_32B), + "sensenova": ("sensenova", SENSENOVA_MODEL), +} +# 打标主用优先级(勾选组合时选最高的) +CAPTION_PRIORITY = ["omlx-32b", "omlx", "ollama", "sensenova"] + + +def _norm_backend(b): + """打标/描述用单个后端。若传入列表(勾选组合),按优先级取主用:omlx-32b > omlx > ollama > sensenova""" + if isinstance(b, (list, tuple)): + for pref in CAPTION_PRIORITY: + if pref in b: + return pref + return "ollama" + return b + + +def _resolve_model(backend): + """返回 (base_url, model_name)。omlx-32b / omlx 都用小果 URL,只是模型不同""" + src, model = BACKEND_MODELS.get(backend, ("ollama", OLLAMA_MODEL)) + if src == "omlx": + return OMLX_URL, model + if src == "sensenova": + return SENSENOVA_URL, model + return OLLAMA_URL, model + + +def describe_image_omlx(img_path, timeout=120, face_only=False, backend="ollama"): + """ + VLM 图像识别(看图描述人物)。backend: + "ollama" = 本地 qwen3-vl-abliterated:8b(默认,稳定自主); + "omlx" = 小果 Qwen3-VL-30B-A3B-Thinking-4bit(细节更准,串行调用,依赖小果在线)。 + face_only=True:只描述面部特征+发型(特写用,不提服装/背景/姿势); + face_only=False:完整描述(姿势/服装/光线/背景/发型)。 + 返回结构化描述,用于生成准确 caption。 + """ + import base64, json, urllib.request + backend = _norm_backend(backend) + with open(img_path, "rb") as f: + b64 = base64.b64encode(f.read()).decode() + if face_only: + prompt = ( + "请只描述这张图片中人物的**表情、妆容和发型**,不要描述肤色、斑点、五官、脸型等面部特征," + "不要描述服装、背景、姿势。\n" + "格式:表情(微笑/露齿笑/大笑/中性/严肃/惊讶等), 妆容(淡妆/浓妆/口红/眼影等), 发型(发色/长度/造型/发饰)。\n" + "要求:只要短语,不要解释,不要思考过程。\n" + "例:大笑, 淡妆口红, 黑色齐刘海短发" + ) + else: + prompt = ( + "请用简洁的中文短语描述这张图片中的人物,格式:姿势, 表情, 服装, 光线, 背景, 妆容, 发型。\n" + "绝对不要描述肤色、斑点、五官、脸型等面部特征。\n" + "要求:只要短语,不要解释,不要思考过程,不要分点。\n" + "例:站立, 微笑, 红色礼服, 自然光, 户外花园, 淡妆, 长发马尾" + ) + try: + if backend == "omlx": + payload = { + "model": _resolve_model(backend)[1], + "messages": [{"role": "user", "content": [ + {"type": "text", "text": prompt}, + {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{b64}"}}, + ]}], + "max_tokens": 2048, + } + req = urllib.request.Request(OMLX_URL, data=json.dumps(payload).encode(), + headers={"Content-Type": "application/json"}, method="POST") + with urllib.request.urlopen(req, timeout=timeout) as resp: + result = json.loads(resp.read()) + text = (result["choices"][0]["message"].get("content") or "").strip() + else: + payload = { + "model": OLLAMA_MODEL, + "messages": [{"role": "user", "content": prompt, "images": [b64]}], + "stream": False, + } + req = urllib.request.Request(OLLAMA_URL, data=json.dumps(payload).encode(), + headers={"Content-Type": "application/json"}, method="POST") + with urllib.request.urlopen(req, timeout=timeout) as resp: + result = json.loads(resp.read()) + text = result["message"]["content"].strip() + _skip_prefix = ("用户", "我", "这", "图片", "根据", "请", "好", "以下", "要", "所以") + for line in text.splitlines(): + line = line.strip().strip("*#") + # Thinking 模型 content 可能带"要简洁短语,所以整合:xxx"前言 → 取冒号后实质内容 + if (":" in line or ":" in line) and line.startswith(_skip_prefix): + line = line.replace(":", ":").split(":")[-1].strip() + if "," in line and len(line) < 120 and not line.startswith(_skip_prefix): + return line + # fallback:跳过思考/寒暄行,取第一条实质内容(修 30B content 混入 thinking 开头的问题) + for line in text.splitlines(): + line = line.strip().strip("*#") + if line and not line.startswith(_skip_prefix): + return line[:120] + return "" + except Exception as e: + return f"(VLM[{backend}] 调用失败: {e})" +# 默认筛选配额(总 20) +DEFAULT_QUOTA = {"特写": 10, "半身": 6, "全身": 4} +# 配额装满后补录线:同类未选中里质量 ≥ 此值的图直接晋级(高分不因配额被挤掉) +TOPUP_QUALITY = 90 + +# 同场景去重:pHash 距离 ≤ 阈值 或 HSV 直方图相关性 ≥ 阈值 视为同批次,每簇最多保留 MAX_PER_CLUSTER 张 +# (pHash 对"同造型微调姿势"失效——实测双胞胎距离 69/256;直方图 0.971 精准锁定) +DUP_PHASH_THRESHOLD = 12 +DUP_HIST_CORREL = 0.85 +MAX_PER_CLUSTER = 1 + + +def _hsv_hist(img): + """HSV 颜色直方图(16×8×8),捕捉同背景同服装同造型(pHash 抓不住的批次特征)""" + rgb = np.array(img.convert("RGB").resize((256, 256))) + hsv = cv2.cvtColor(rgb, cv2.COLOR_RGB2HSV) + h = cv2.calcHist([hsv], [0, 1, 2], None, [16, 8, 8], [0, 180, 0, 256, 0, 256]) + cv2.normalize(h, h) + return h + + +def _same_cluster(a, b): + """两张图是否同批次:pHash 近(同构图)或直方图近似(同背景同造型)""" + if hamming(np.array(a["phash"]), np.array(b["phash"])) <= DUP_PHASH_THRESHOLD: + return True + if a.get("hist") is not None and b.get("hist") is not None: + return cv2.compareHist(a["hist"], b["hist"], cv2.HISTCMP_CORREL) >= DUP_HIST_CORREL + return False + +ANGLE_VLM_PROMPT = ( + "判断这张图片中人物脸部的朝向和拍摄角度。只回答以下之一:\n" + "正面(脸部正对镜头,双眼基本对称,平视)\n" + "前侧(脸部转向一侧约30-60度,双眼可见但不对称)\n" + "侧面(脸部转向一侧约70-90度,只能看到一只眼睛)\n" + "俯拍(相机从上方俯视拍摄,能看到头顶/发旋,额头偏大,下巴偏小)\n" + "仰拍(相机从下方仰视拍摄,能看到下巴下侧/鼻孔,下巴偏大,额头偏小)\n" + "注意:判断的是拍摄机位高低,不是人物朝向。只回答'正面'、'前侧'、'侧面'、'俯拍'、'仰拍'一个词。" +) +_ANGLE_ZH2EN = {"正脸": "front view", "正面": "front view", "半侧面": "three-quarter view", + "前侧": "three-quarter view", "侧面": "side view", + "俯拍": "high angle view", "仰拍": "low angle view"} + + +def classify_angle_vlm(img_path, backend="ollama", timeout=120): + """ + VLM 语义判断人脸朝向(比 5-landmark 几何法可靠得多:大侧脸/微侧都能判准)。 + 返回 "front view" / "three-quarter view" / "side view";失败返回 None(调用方退回几何法)。 + """ + backend = _norm_backend(backend) + import base64, json, urllib.request + with open(img_path, "rb") as f: + b64 = base64.b64encode(f.read()).decode() + try: + if backend == "omlx": + payload = { + "model": _resolve_model(backend)[1], + "messages": [{"role": "user", "content": [ + {"type": "text", "text": ANGLE_VLM_PROMPT}, + {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{b64}"}}, + ]}], + "max_tokens": 1024, + } + req = urllib.request.Request(OMLX_URL, data=json.dumps(payload).encode(), + headers={"Content-Type": "application/json"}, method="POST") + with urllib.request.urlopen(req, timeout=timeout) as resp: + result = json.loads(resp.read()) + text = (result["choices"][0]["message"].get("content") or "").strip() + else: + payload = { + "model": OLLAMA_MODEL, + "messages": [{"role": "user", "content": ANGLE_VLM_PROMPT, "images": [b64]}], + "stream": False, + } + req = urllib.request.Request(OLLAMA_URL, data=json.dumps(payload).encode(), + headers={"Content-Type": "application/json"}, method="POST") + with urllib.request.urlopen(req, timeout=timeout) as resp: + result = json.loads(resp.read()) + text = result["message"]["content"].strip() + for zh, en in _ANGLE_ZH2EN.items(): + if zh in text: + return en + return None + except Exception: + return None + + +def classify_expression_vlm(img_path, backend="ollama", timeout=120): + """ + VLM 判表情(大笑/露齿笑/微笑/中性/严肃/惊讶/其他)——多表情覆盖是脸部 LoRA 泛化的关键。 + 返回表情中文词;失败返回 None。 + """ + import base64, json, urllib.request + with open(img_path, "rb") as f: + b64 = base64.b64encode(f.read()).decode() + _prompt = ( + "判断这张图片中人物的表情。只回答以下之一:微笑/露齿笑/大笑/中性/严肃/惊讶/其他。\n" + "微笑=嘴角微上扬不露齿;露齿笑=笑容露出牙齿;大笑=张嘴大笑/笑得很开。\n" + "只回答一个词。" + ) + try: + if backend == "omlx": + payload = {"model": _resolve_model(backend)[1], "messages": [{"role": "user", "content": [ + {"type": "text", "text": _prompt}, + {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{b64}"}}]}], + "max_tokens": 512} + req = urllib.request.Request(OMLX_URL, data=json.dumps(payload).encode(), + headers={"Content-Type": "application/json"}, method="POST") + with urllib.request.urlopen(req, timeout=timeout) as resp: + result = json.loads(resp.read()) + text = (result["choices"][0]["message"].get("content") or "").strip() + else: + payload = {"model": OLLAMA_MODEL, "messages": [{"role": "user", "content": _prompt, "images": [b64]}], "stream": False} + req = urllib.request.Request(OLLAMA_URL, data=json.dumps(payload).encode(), + headers={"Content-Type": "application/json"}, method="POST") + with urllib.request.urlopen(req, timeout=timeout) as resp: + result = json.loads(resp.read()) + text = result["message"]["content"].strip() + for kw in ("微笑", "露齿笑", "大笑", "中性", "严肃", "惊讶"): + if kw in text: + return kw + return "其他" + except Exception: + return None + + +# ===== 集成投票分类(3 模型自由描述 + 多数投票,比单模型强制单选可靠得多)===== +FREE_DESCRIBE_PROMPT = ( + "请观察这张人物照片,用自然语言描述:\n" + "1) 表情状态(嘴角/嘴型/眉毛的具体状态:是微笑上扬、露齿、大笑张嘴、还是平静/严肃/惊讶?)\n" + "2) 拍摄角度(相机位置:平视?从上方俯视能看到头顶?从下方仰视能看到下巴下侧?" + "人物脸正对镜头还是转向一侧、约多少度?)\n" + "只描述你实际看到的视觉特征,不要给分类标签。" +) + +def _free_describe(img_path, backend, timeout=120): + """各后端自由描述图片,返回自然语言描述(失败返回 None)""" + import base64, json, urllib.request + with open(img_path, "rb") as f: + b64 = base64.b64encode(f.read()).decode() + try: + if backend == "sensenova": + payload = {"model": SENSENOVA_MODEL, "messages": [{"role": "user", "content": [ + {"type": "text", "text": FREE_DESCRIBE_PROMPT}, + {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{b64}"}}]}], + "max_tokens": 512} + req = urllib.request.Request(SENSENOVA_URL, data=json.dumps(payload).encode(), + headers={"Content-Type": "application/json", "Authorization": f"Bearer {SENSENOVA_KEY}"}, + method="POST") + with urllib.request.urlopen(req, timeout=timeout) as resp: + return json.loads(resp.read())["choices"][0]["message"]["content"].strip() + elif backend in ("omlx", "omlx-32b"): + payload = {"model": _resolve_model(backend)[1], "messages": [{"role": "user", "content": [ + {"type": "text", "text": FREE_DESCRIBE_PROMPT}, + {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{b64}"}}]}], + "max_tokens": 512} + req = urllib.request.Request(OMLX_URL, data=json.dumps(payload).encode(), + headers={"Content-Type": "application/json"}, method="POST") + with urllib.request.urlopen(req, timeout=timeout) as resp: + msg = json.loads(resp.read())["choices"][0]["message"] + return (msg.get("content") or msg.get("reasoning_content") or "").strip() + else: # ollama + payload = {"model": OLLAMA_MODEL, "messages": [{"role": "user", "content": FREE_DESCRIBE_PROMPT, "images": [b64]}], "stream": False} + req = urllib.request.Request(OLLAMA_URL, data=json.dumps(payload).encode(), + headers={"Content-Type": "application/json"}, method="POST") + with urllib.request.urlopen(req, timeout=timeout) as resp: + return json.loads(resp.read())["message"]["content"].strip() + except Exception: + return None + + +def _extract_angle(text): + """从自由描述中提取角度。极度保守:只在描述明确无歧义时才判定,否则返回 None(不瞎标)。""" + if not text: + return None + # 俯拍:必须明确"头顶/发旋/上方俯视" + if any(k in text for k in ("能看到头顶", "看到头顶", "头顶上方", "从上方俯视", "明显俯视", "俯拍", "从上往下")): + return "high angle view" + # 仰拍:必须明确"下巴下侧/从下往上/仰视" + if any(k in text for k in ("能看到下巴下侧", "看到下巴下侧", "从下方仰视", "明显仰视", "仰拍", "从下往上", "鼻孔下方视角")): + return "low angle view" + # 侧面:必须明确"只能看到一只眼睛"或"约90度"或"正侧面" + if any(k in text for k in ("只能看到一只眼睛", "仅能看到一只眼睛", "正侧面", "完全侧", "侧脸90")): + return "side view" + # 半侧:明确"约30-60度"/"半侧面"/"明显偏转但不是90" + if any(k in text for k in ("约30", "约45", "30-60", "半侧面", "偏转约")): + return "three-quarter view" + # 正面:明确"正对镜头/直视/完全正面" + if any(k in text for k in ("正对镜头", "直视镜头", "完全正面", "正面朝向", "正面平视")): + return "front view" + # 其他情况不判定(避免"转向一侧""鼻孔"等模糊词误触发) + return None + + +def _extract_expr(text): + """从自由描述中提取表情。保守:明确特征才判定,模糊不标。""" + if not text: + return None + if any(k in text for k in ("大笑", "张嘴大笑", "笑得很开", "开怀大笑", "哈哈大笑")): + return "大笑" + if any(k in text for k in ("露齿", "露出牙齿", "咧嘴笑", "露牙")): + return "露齿笑" + if any(k in text for k in ("惊讶", "瞪大眼睛", "眼睛睁大", "眉毛挑高", "挑眉")): + return "惊讶" + if any(k in text for k in ("严肃", "肃穆", "面无表情", "没有笑容")): + return "严肃" + if any(k in text for k in ("中性", "平静", "淡然", "自然表情")): + return "中性" + if any(k in text for k in ("微笑", "嘴角上扬", "浅浅的笑", "温和的笑", "微笑")): + return "微笑" + return None + + +def classify_ensemble_vlm(img_path, backends=("omlx", "ollama", "sensenova", "omlx-32b"), timeout=120): + """ + 三投票:多模型自由描述 + 规则提取 + 多数投票判 角度/表情。 + 多后端并行调用(本地 ollama / 小果 omlx / 云端 sensenova 互相独立;每后端仅 1 路)。 + 同源去重:omlx 与 omlx-32b 是同一台 oMLX 服务器——并发 2 路会撑爆小果内存,只保留优先级更高的 omlx-32b。 + 返回 (angle_en, expr_zh, confidence): + confidence = "一致"(全同) / "多数"(2/3同) / "不确定"(全分歧,需人工) + 失败/无法返回 (None, None, "失败")。 + """ + from concurrent.futures import ThreadPoolExecutor, as_completed + # 同源去重:同一服务器只保留一路(omlx-32b 比 omlx 强,优先) + backends = tuple(b for b in backends if not (b == "omlx" and "omlx-32b" in backends)) + if not backends: + return None, None, "失败" + angles, exprs = [], [] + # 整体硬性超时:单张图全后端判定最多等 timeout 秒,超时即收已完成的(其他后端后台继续,不阻塞); + # 防止某个后端(如小果 oMLX)挂起时无限拖住整条流水线 + ex = ThreadPoolExecutor(max_workers=len(backends)) + try: + futs = {ex.submit(_free_describe, img_path, b, timeout=timeout): b for b in backends} + try: + for f in as_completed(futs, timeout=timeout): + try: + desc = f.result() + except Exception: + desc = None + if desc: + a = _extract_angle(desc) + e = _extract_expr(desc) + if a: + angles.append(a) + if e: + exprs.append(e) + except TimeoutError: + pass # 整体超时:收下已完成的后端结果,其余后台继续 + finally: + ex.shutdown(wait=False) # 不等待挂起线程,立即返回,避免 with 退出时二次阻塞 + if not angles and not exprs: + return None, None, "失败" + + def vote(vals): + from collections import Counter + if not vals: + return None + c = Counter(vals) + top, n = c.most_common(1)[0] + if n >= 2: + return top, "多数" if n < len(vals) else "一致" + return top, "不确定" # 全分歧,取第一个(标记人工复核) + + ang, ang_conf = vote(angles) + expr, expr_conf = vote(exprs) + conf = "失败" + if ang_conf == "一致" or expr_conf == "一致": + conf = "一致" + elif ang_conf == "多数" or expr_conf == "多数": + conf = "多数" + elif ang_conf == "不确定" or expr_conf == "不确定": + conf = "不确定" + return ang, expr, conf + + +def vlm_caption(img_path, kind, backend="ollama", trigger="lm_face_v1", angle_en=None): + """ + 统一 caption 生成(auto 流水线 / 候选换图 / 打标重新生成 三处共用的唯一实现): + VLM 判角度(可用 angle_en 参数跳过重复判定)+ VLM 描述(特写只描述面部+发型)+ 模板拼装。 + kind: 特写/半身/全身/其他(其他无模板短语)。 + 返回 (caption, angle_en);角度判定失败返回 (None, None)。 + """ + if not angle_en: + angle_en = classify_angle_vlm(str(img_path), backend=backend) + if not angle_en: + return None, None + desc = describe_image_omlx(str(img_path), face_only=(kind == "特写"), backend=backend) + tpl = AUTO_TEMPLATES.get(kind, "") + if tpl: + caption = f"{trigger}, {angle_en} {tpl}, {desc}" if desc else f"{trigger}, {angle_en} {tpl}" + else: + caption = f"{trigger}, {angle_en}, {desc}" if desc else f"{trigger}, {angle_en}" + return caption, angle_en + + +def face_quality_score(img, face_box, lap_face=None): + """ + 综合质量分 0-100:人脸像素 + 面部清晰度 + 分辨率。 + 权重设计:清晰度(35) 略低于像素(40),但"脸小极清晰"仍能胜过"脸大偏糊"。 + """ + score = 0 + # 人脸绝对像素(决定 LoRA 训练有效性) + if face_box: + fw, fh = face_box[2], face_box[3] + face_px = min(fw, fh) + if face_px >= 300: + score += 40 + elif face_px >= 200: + score += 32 + elif face_px >= 150: + score += 22 + elif face_px >= 100: + score += 12 + else: + score += 4 + # 面部清晰度(人脸区域 Laplacian;真实照片细腻纹理值域 20-100+,雀斑清晰图约 25-60) + if lap_face is not None: + if lap_face >= 100: + score += 35 + elif lap_face >= 60: + score += 30 + elif lap_face >= 35: + score += 24 + elif lap_face >= 20: + score += 15 + elif lap_face >= 10: + score += 6 + # 分辨率 + short = min(img.size) + if short >= 1500: + score += 25 + elif short >= 1200: + score += 22 + elif short >= 1024: + score += 18 + elif short >= 800: + score += 10 + else: + score += 3 + return min(100, score) + + +def _lap_of(img, face_box=None): + """计算(人脸区域/全图)Laplacian 清晰度值""" + gray = np.array(img.convert("L")) + if face_box is not None: + x, y, w, h = [int(v) for v in face_box] + hh, ww = gray.shape + x0, y0 = max(0, x - w), max(0, y - h) + x1, y1 = min(ww, x + 2 * w), min(hh, y + 2 * h) + if (x1 - x0) > 40 and (y1 - y0) > 40: + gray = gray[y0:y1, x0:x1] + scale = 1024.0 / max(gray.shape) + if scale < 1.0: + gray = cv2.resize(gray, None, fx=scale, fy=scale, interpolation=cv2.INTER_AREA) + return float(cv2.Laplacian(gray, cv2.CV_64F).var()) + + +# 角度保底配额:非正面素材稀缺且对泛化至关重要,优先必录(候选有才占名额) +ANGLE_MIN_QUOTA = {"side view": 2, "three-quarter view": 3, "high angle view": 1, "low angle view": 1} +# 表情保底配额:多表情覆盖是脸部 LoRA 泛化的关键(露齿笑/大笑/惊讶等稀缺表情优先必录) +EXPR_MIN_QUOTA = {"大笑": 2, "露齿笑": 2, "惊讶": 1} + + +def smart_pick(kind_items, target, quality_key="quality"): + """ + 每类内部筛选:角度保底 + 表情保底 + 同簇去重硬上限 + 质量 + 贪心多样性。 + 角度优先用 item["angle_en"](VLM 判定),表情用 item["expr"](VLM 判定)。 + kind_items: [result_dict,...];返回选中的列表,且给每项加 pick_rank。 + """ + def _ang(r): + return r.get("angle_en") or face_angle(r["face"])[1] + + def _expr(r): + return r.get("expr") or "其他" + + items = sorted(kind_items, key=lambda r: -r[quality_key]) + if not items: + return [] + selected, rest = [], list(items) + # 1. 角度保底:side view / three-quarter view 各保前 N 张最高质量(候选不足则跳过) + for ang, min_n in ANGLE_MIN_QUOTA.items(): + cands = [r for r in rest if _ang(r) == ang] + for best in cands[:min_n]: + if len(selected) >= target: + break + selected.append(best) + rest.remove(best) + # 2. 表情保底:大笑/张嘴/严肃 优先必录(稀缺表情对泛化价值高) + for expr, min_n in EXPR_MIN_QUOTA.items(): + cands = [r for r in rest if _expr(r) == expr] + for best in cands[:min_n]: + if len(selected) >= target: + break + selected.append(best) + rest.remove(best) + # 3. 剩余名额:同簇去重硬上限 + 质量 + pHash 多样性贪心 + while len(selected) < min(target, len(items)) and rest: + best_i, best_score = -1, -1e9 + for i, it in enumerate(rest): + # 与已选集合的最小 phash 距离 + d = min(hamming(np.array(it["phash"]), np.array(s["phash"])) for s in selected) if selected else 0 + # 同簇硬上限:pHash 近或直方图近似(同批次)且簇已满 → 禁止入选 + if sum(1 for s in selected if _same_cluster(it, s)) >= MAX_PER_CLUSTER: + continue + score = it[quality_key] * 0.6 + min(d, 40) * 0.4 + if score > best_score: + best_score, best_i = score, i + if best_i < 0: + break # 剩余全部撞簇上限 + selected.append(rest.pop(best_i)) + for i, r in enumerate(selected): + r["pick_rank"] = i + 1 + return selected + + +def auto_process(src_dir, out_dir, face_det, person_det, trigger="lm_face_v1", + face_margin=1.8, min_side=MIN_RESOLUTION, quota=None, limit=True, + use_omlx=False, backend="ollama"): + """ + 全自动素材流水线(v2): + 1. 逐张:人脸+人体检测;面部清晰度/整体清晰度分开 + 2. 综合质量评分(脸像素+清晰度+分辨率) + 3. 构图决策(占比+脸像素双指标)并裁剪: + - 占比≥8% 或 脸短边≥256px → 特写候选(裁脸) + - 占比≥2% 且 脸短边≥150px → 半身 + - 有人体 → 全身候选(裁全身) + - 裁后短边 1 else faces[0] + fx, fy, fw, fh = int(f[0]), int(f[1]), int(f[2]), int(f[3]) + ratio = (fw * fh) / (w * h) + face_px = min(fw, fh) + + # 人脸像素判定:脸短边 <250px 才淘汰(高清面部图 250px+ 就够训练) + if face_px < 250: + rejected.append((p.name, f"人脸像素不足(脸短边 {face_px}px < 250px,训练效果差)")) + print(f"[淘汰] {p.name}: 人脸像素不足({face_px}px)") + continue + + # 面部清晰度:紧贴脸框核心(避免背景稀释)。 + # 注意:真实照片的雀斑/眉毛等细腻纹理 Laplacian 值较低(20-40 正常), + # 阈值 15 能区分"清晰"(>20)和"真糊"(<15,对焦失败/强模糊)。 + lap_face = face_sharpness(img, (fx, fy, fw, fh)) + if lap_face < 15: + rejected.append((p.name, f"面部模糊(清晰度 {lap_face:.0f},阈值 15)")) + print(f"[淘汰] {p.name}: 面部模糊") + continue + lap_all = _lap_of(img, None) + + item = { + "file": p.name, "src": p, "img": img, + "w": w, "h": h, "ratio": ratio, "face_px": face_px, + "face": list(f), # 完整 face(含 landmarks,face_angle 需要) + "lap_face": lap_face, "lap_all": lap_all, + "faces_n": len(faces), "persons": persons, + "phash": phash(img).tolist(), + "hist": _hsv_hist(img), + } + item["quality"] = face_quality_score(img, (fx, fy, fw, fh), lap_face) + + # 构图分类(主要看人脸占比,位置仅辅助全身判定) + # 特写:脸是画面主体(≥8%);半身/胸像:上半身可见(1.5-8%);全身:整个人可见(<1.5%) + face_cy = fy + fh / 2 + face_top = face_cy < h * 0.5 # 人脸在上半部(全身照特征) + if ratio >= 0.08: + kind = "特写" + elif ratio >= 0.015: + kind = "半身" + else: + # 占比小:人脸在上部 → 全身(人物从上到下);人脸在中下部 → 半身(大头照/脸部局部) + kind = "全身" if face_top else "半身" + item["kind"] = kind + all_items[kind].append(item) + + print(f"[{kind}] {p.name} | 脸{face_px}px 占比{ratio*100:.1f}% 清晰{lap_face:.0f}/{lap_all:.0f} 质量{item['quality']}") + + except Exception as e: + rejected.append((p.name, f"处理异常: {e}")) + print(f"[异常] {p.name}: {e}") + + # VLM 角度分类(语义判断,远比 5-landmark 几何法准:大侧脸/微侧都能判对)+ 表情分类(多表情覆盖)。 + # 串行调用(并发会撑爆小果统一内存);不开 VLM 时退回几何法并在标签阶段标注。 + if use_omlx: + all_cands = [r for rs in all_items.values() for r in rs] + print(f"\n[VLM 角度+表情分类] {len(all_cands)} 张候选,串行判定中(models={backend})...") + if isinstance(backend, (list, tuple)) and len(backend) > 1: + _models = tuple(backend) + for r in all_cands: + ang, expr, conf = classify_ensemble_vlm(str(r["src"]), backends=_models) + r["angle_en"] = ang or face_angle(r["face"])[1] + r["expr"] = expr or "其他" + print(f" [分类] {r['file']}: 角度={r['angle_en']} 表情={r['expr']} 置信={conf}") + else: + _bk = _norm_backend(backend) + for r in all_cands: + ang = classify_angle_vlm(str(r["src"]), backend=_bk) + r["angle_en"] = ang or face_angle(r["face"])[1] + r["expr"] = classify_expression_vlm(str(r["src"]), backend=_bk) or "其他" + print(f" [分类] {r['file']}: 角度={r['angle_en']} 表情={r['expr']}") + if ang is None: + print(f" [WARN] {r['file']}: VLM 角度失败,退回几何法({r['angle_en']})") + else: + print("\n[角度] 未开 VLM(--omlx),用几何法粗判(大侧脸/微侧可能不准,建议开 VLM)") + + # 超量筛选:每类按配额挑最优;配额装满后,同类未选中里质量 ≥ TOPUP_QUALITY 的直接补录(高分不因配额被挤掉) + picked_all, unused_all = [], [] + quota = quota or DEFAULT_QUOTA + for kind, items in all_items.items(): + if not items: + continue + target = quota.get(kind, 0) if limit else len(items) + picked = smart_pick(items, target) + if limit and len(items) > target: + # 补录:同类未选中里质量达标者全部晋级(不设上限,高分全收) + used_ids = {id(r) for r in picked} + topup = [r for r in items if id(r) not in used_ids and r.get("quality", 0) >= TOPUP_QUALITY] + topup.sort(key=lambda r: -r.get("quality", 0)) + for r in topup: + r["pick_rank"] = len(picked) + 1 + picked.append(r) + if topup: + print(f"[补录] {kind}: 质量≥{TOPUP_QUALITY} 的未选中图补录 {len(topup)} 张({', '.join(r['file'] for r in topup[:5])}{'...' if len(topup)>5 else ''})") + picked_all.extend(picked) + used_ids = {id(r) for r in picked} + unused_all.extend(r for r in items if id(r) not in used_ids) + + # 写出选中素材:每图可产出 特写(裁脸) + 构图素材(半身/全身) + counts = {k: 0 for k in AUTO_TEMPLATES} + for r in sorted(picked_all, key=lambda x: ({"特写": 0, "半身": 1, "全身": 2}[x["kind"]], x["pick_rank"])): + kind = r["kind"] + img = r["img"] + face = r["face"] + + # 1. 脸部特写:只要人脸像素足够(≥150)就额外裁一张特写(一张图可产多个素材) + # 标签:角度 + **只描述面部+发型**(特写只剩面部,不含被裁掉的服装/背景/姿势) + if r["face_px"] >= 150: + crop_p, note_p = crop_face_portrait(img, face, margin=2.5) + angle_en = r.get("angle_en") or face_angle(face)[1] + angle_zh = {"front view": "正脸", "three-quarter view": "半侧面", "side view": "侧面", "high angle view": "俯拍", "low angle view": "仰拍"}.get(angle_en, angle_en) + counts["特写"] += 1 + fname_p = f"face_{counts['特写']:03d}.jpg" + crop_p.save(subdirs["特写"] / fname_p, quality=95) + # 特写 caption:face_only(只描述面部+发型,不提被裁掉的服装/背景/姿势) + if use_omlx: + caption_p, _ = vlm_caption(str(r["src"]), "特写", backend=backend, trigger=trigger, angle_en=angle_en) + caption_p = caption_p or f"{trigger}, {angle_en} {AUTO_TEMPLATES['特写']}" + else: + caption_p = f"{trigger}, {angle_en} {AUTO_TEMPLATES['特写']}" + (subdirs["特写"] / f"face_{counts['特写']:03d}.txt").write_text(caption_p, encoding="utf-8") + print(f" [√] 特写/{fname_p} | {note_p} | {angle_zh} | {caption_p[:60]}") + + # 2. 构图素材:半身/全身(标签按角度 + oMLX 自动描述) + if kind == "特写": + # 本来就是特写占比:保留原图(脸部已占主体) + crop, note = img.copy(), f"特写原图 {r['w']}×{r['h']}" + kind = "特写" + # 特写不再重复输出(上面已产 face_xxx),跳过构图素材 + if r["face_px"] >= 150: + continue + elif kind == "半身": + crop, note = crop_centered_halfbody(img, face) + else: + crop, note = crop_fullbody(img, face) + + counts[kind] += 1 + stem = {"特写": "face", "半身": "half", "全身": "full"}[kind] + fname = f"{stem}_{counts[kind]:03d}.jpg" + crop.save(subdirs[kind] / fname, quality=95) + # 构图素材 caption:角度 + VLM 自动描述(匹配图) + angle_en = r.get("angle_en") or face_angle(face)[1] + angle_zh = {"front view": "正脸", "three-quarter view": "半侧面", "side view": "侧面", "high angle view": "俯拍", "low angle view": "仰拍"}.get(angle_en, angle_en) + if use_omlx: + caption, _ = vlm_caption(str(r["src"]), kind, backend=backend, trigger=trigger, angle_en=angle_en) + caption = caption or f"{trigger}, {angle_en} {AUTO_TEMPLATES[kind]}" + else: + caption = f"{trigger}, {angle_en} {AUTO_TEMPLATES[kind]}" + (subdirs[kind] / f"{stem}_{counts[kind]:03d}.txt").write_text(caption, encoding="utf-8") + print(f" [√] {kind}/{fname} | {note} | {angle_zh} | {caption[:60]}") + + # 未选中 + 淘汰(始终覆盖——修 bug:之前 exists 跳过导致旧版残留缓存,用户修正的图不被更新) + for r in unused_all: + dst = unused_dir / r["file"] + dst.write_bytes(r["src"].read_bytes()) + if rejected: + (reject_dir / "淘汰原因.txt").write_text("\n".join(f"{n}: {r}" for n, r in rejected), encoding="utf-8") + for n, r in rejected: + src = src_dir / n + if src.exists(): + dst = reject_dir / n + dst.write_bytes(src.read_bytes()) + + print(f"\n===== 流水线完成 =====") + for k in AUTO_TEMPLATES: + print(f" {k}: {counts[k]} 张 -> {subdirs[k]}") + print(f" 未选中: {len(unused_all)} 张(见 {unused_dir})") + print(f" 淘汰: {len(rejected)} 张(原因见 {reject_dir}/淘汰原因.txt)") + return counts, rejected + + +# ---------- HTML 报告 ---------- +def build_html(results, out_path): + cards = [] + for r in results: + color = {STATUS["PASS"]: "#16a34a", STATUS["WARN"]: "#d97706", STATUS["FAIL"]: "#dc2626"}[r["status"]] + reasons_html = "
".join(f"· {x}" for x in r["reasons"]) + size = f"{r['size'][0]}×{r['size'][1]}" if r["size"][0] else "?" + cards.append(f""" +
+
+
+
{r['status']}{r['file']}
+
{size} · {r['bytes']/1024:.0f}KB
+
{reasons_html}
+
+
""") + + counts = {v: sum(1 for r in results if r["status"] == v) for v in STATUS.values()} + html = f""" + +LoRA 素材审核报告 + +

📸 LoRA 训练素材审核报告

+
+ ✅ 合格: {counts['合格']} + 🟡 警告: {counts['警告']} + ❌ 不合格: {counts['不合格']} + 共 {len(results)} 张 +
+
{''.join(cards)}
+""" + Path(out_path).write_text(html, encoding="utf-8") + return out_path + + +# ---------- 整理训练集 ---------- +def prepare_dataset(img_dir, results, out_dir, min_status="WARN"): + """把达到最低状态(默认 WARN,即排除 FAIL)的图片复制到训练集并重命名""" + out_dir = Path(out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + ok = [r for r in results if r["status"] in ("PASS", "WARN") and r["status"] != "FAIL"] + # 按状态排序:PASS 优先 + ok.sort(key=lambda r: 0 if r["status"] == "PASS" else 1) + + copied = 0 + for idx, r in enumerate(ok, 1): + src = Path(r["path"]) + dst = out_dir / f"img_{idx:03d}.jpg" + try: + img = load_image(src).convert("RGB") + img.save(dst, quality=95) + copied += 1 + print(f" [OK] {r['file']} -> {dst.name} ({r['status']})") + except Exception as e: + print(f" [ERR] {r['file']} 复制失败: {e}") + print(f"\n共整理 {copied} 张到 {out_dir}") + return copied + + +# ---------- CLI ---------- +def main(): + # Windows 控制台 GBK 编码兜底,避免 emoji/特殊字符打印报错 + for stream in (sys.stdout, sys.stderr): + try: + stream.reconfigure(encoding="utf-8", errors="replace") + except Exception: + pass + + parser = argparse.ArgumentParser(description="LoRA 训练素材审核与整理工具") + sub = parser.add_subparsers(dest="cmd", required=True) + + p_check = sub.add_parser("check", help="审核照片,生成报告") + p_check.add_argument("dir", help="照片目录") + p_check.add_argument("--out", default=None, help="报告输出目录(默认同照片目录)") + + p_prep = sub.add_parser("prepare", help="整理合格照片到训练集") + p_prep.add_argument("dir", help="照片目录") + p_prep.add_argument("--out", required=True, help="训练集输出目录") + + p_batch = sub.add_parser("batch", help="审核 + 整理一条龙") + p_batch.add_argument("dir", help="照片目录") + p_batch.add_argument("--out", required=True, help="训练集输出目录") + + p_pick = sub.add_parser("pick", help="智能选图:自动挑出多样化的 N 张") + p_pick.add_argument("dir", help="照片目录") + p_pick.add_argument("--count", type=int, default=20, help="要选多少张(默认 20)") + p_pick.add_argument("--out", default=None, help="报告输出目录(默认同照片目录)") + + p_crop = sub.add_parser("crop", help="合影处理:按最大人脸裁切单人(处理多人合影)") + p_crop.add_argument("dir", help="照片目录") + p_crop.add_argument("--out", required=True, help="裁切输出目录") + p_crop.add_argument("--scale", type=float, default=1.8, help="裁切放大倍数(默认 1.8)") + + p_auto = sub.add_parser("auto", help="全自动流水线:构图分类+自动裁剪+打标草稿+超量筛选") + p_auto.add_argument("dir", help="照片目录") + p_auto.add_argument("--out", required=True, help="输出目录(生成 特写/半身/全身/未选中/淘汰)") + p_auto.add_argument("--trigger", default="lm_face_v1", help="触发词(默认 lm_face_v1)") + p_auto.add_argument("--limit", type=int, default=20, + help="目标筛选数量(默认 20:特写10/半身6/全身4;0=不筛选全部保留)") + p_auto.add_argument("--omlx", action="store_true", + help="用 VLM 自动描述图像(姿势/服装/光线/背景/发型,匹配图)") + p_auto.add_argument("--backend", choices=["ollama", "omlx"], default="ollama", + help="VLM 后端:ollama=本地8B(默认,稳定);omlx=小果30B-Thinking(细节更准,串行)") + + args = parser.parse_args() + + detector = FaceDetector() + + if args.cmd == "check": + results = analyze_images(args.dir, detector) + out_dir = Path(args.out) if args.out else Path(args.dir) + out_dir.mkdir(parents=True, exist_ok=True) + report = out_dir / "素材审核报告.html" + build_html(results, report) + (out_dir / "素材审核结果.json").write_text(json.dumps(results, ensure_ascii=False, indent=1), encoding="utf-8") + print(f"\n报告已生成: {report}") + + elif args.cmd == "prepare": + results = analyze_images(args.dir, detector, verbose=False) + prepare_dataset(args.dir, results, args.out) + + elif args.cmd == "batch": + results = analyze_images(args.dir, detector) + out_dir = Path(args.dir) + report = out_dir / "素材审核报告.html" + build_html(results, report) + print(f"报告已生成: {report}") + prepare_dataset(args.dir, results, args.out) + + elif args.cmd == "pick": + results = analyze_images(args.dir, detector) + picked = pick_diverse(results, count=args.count) + out_dir = Path(args.out) if args.out else Path(args.dir) + out_dir.mkdir(parents=True, exist_ok=True) + report = out_dir / f"智能选图推荐_{len(picked)}张.html" + build_pick_html(picked, report, args.count) + (out_dir / "智能选图结果.json").write_text( + json.dumps(picked, ensure_ascii=False, indent=1), encoding="utf-8" + ) + print(f"\n推荐选图报告: {report}") + print(f"共推荐 {len(picked)} 张(目标 {args.count})") + for r in picked: + print(f" [{r.get('compose','')}] {r['file']} ({r['status']})") + + elif args.cmd == "crop": + crop_dir(args.dir, args.out, detector, margin_scale=args.scale) + + elif args.cmd == "auto": + person_det = PersonDetector() + if args.limit > 0: + n1 = max(1, round(args.limit * 0.5)) + n2 = max(1, round(args.limit * 0.3)) + quota = {"特写": n1, "半身": n2, "全身": max(1, args.limit - n1 - n2)} + else: + quota = None + auto_process(args.dir, args.out, detector, person_det, trigger=args.trigger, + quota=quota, limit=args.limit > 0, use_omlx=args.omlx, backend=args.backend) + + +if __name__ == "__main__": + main() diff --git a/tools/merge_fixed.py b/tools/merge_fixed.py new file mode 100644 index 0000000..f3120d7 --- /dev/null +++ b/tools/merge_fixed.py @@ -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() diff --git a/tools/models/face_detection_yunet.onnx b/tools/models/face_detection_yunet.onnx new file mode 100644 index 0000000..f9beb30 Binary files /dev/null and b/tools/models/face_detection_yunet.onnx differ diff --git a/tools/monitor_train_v3.ps1 b/tools/monitor_train_v3.ps1 new file mode 100644 index 0000000..47df643 --- /dev/null +++ b/tools/monitor_train_v3.ps1 @@ -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 +} diff --git a/tools/validate_lora.py b/tools/validate_lora.py new file mode 100644 index 0000000..961d956 --- /dev/null +++ b/tools/validate_lora.py @@ -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() diff --git a/tools/一键审核.bat b/tools/一键审核.bat new file mode 100644 index 0000000..b05edcc --- /dev/null +++ b/tools/一键审核.bat @@ -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 diff --git a/tools/一键选图.bat b/tools/一键选图.bat new file mode 100644 index 0000000..1a32c8b --- /dev/null +++ b/tools/一键选图.bat @@ -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 diff --git a/tools/使用说明.md b/tools/使用说明.md new file mode 100644 index 0000000..f28efac --- /dev/null +++ b/tools/使用说明.md @@ -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 转换 diff --git a/启动控制台.bat b/启动控制台.bat new file mode 100644 index 0000000..c0a5fda --- /dev/null +++ b/启动控制台.bat @@ -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 \ No newline at end of file diff --git a/启动训练.bat b/启动训练.bat new file mode 100644 index 0000000..a25c4b0 --- /dev/null +++ b/启动训练.bat @@ -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 diff --git a/素材/素材准备指南.md b/素材/素材准备指南.md new file mode 100644 index 0000000..6bb62ab --- /dev/null +++ b/素材/素材准备指南.md @@ -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 年的 +- [ ] 无重复角度、无模糊照 diff --git a/部署LoRA.bat b/部署LoRA.bat new file mode 100644 index 0000000..40c53aa --- /dev/null +++ b/部署LoRA.bat @@ -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: ^ +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 diff --git a/项目管理.md b/项目管理.md new file mode 100644 index 0000000..93710ce --- /dev/null +++ b/项目管理.md @@ -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/` 拿真实 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`