# -*- 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")])