tutorials / custom_nodes /dolphin_nodes /lora_profile.py
bjooo's picture
Upload folder using huggingface_hub
9c98083 verified
Raw History Blame Contribute Delete
11.8 kB
# -*- coding: utf-8 -*-
"""
๋กœ๋ผ ํŒŒ์ผ ํ•˜๋‚˜๋ฅผ "LLM์—๊ฒŒ ์„ค๋ช…ํ•  ์ˆ˜ ์žˆ๋Š” ํ”„๋กœํ•„"๋กœ ๋ณ€ํ™˜ํ•œ๋‹ค.
๊ธฐ์กด `get_triggers()`(dolphin_vision_director.py)๋Š” ํŠธ๋ฆฌ๊ฑฐ '๋‹จ์–ด'๋งŒ ๋ฝ‘์•„์„œ
์บ๋ฆญํ„ฐ ์ด๋ฆ„ ์˜†์— ์ฝค๋งˆ๋กœ ๋‚˜์—ดํ•˜๋Š” ์šฉ๋„์˜€๋‹ค. ๊ทธ ๋ฐฉ์‹์€ ๋ฌธ๋ฒ•์  ๋งฅ๋ฝ์ด ์—†์–ด์„œ
`bounce`/`swing` ๊ฐ™์€ ๋ฒ”์šฉ ๋™์ž‘ ๋™์‚ฌ๊ฐ€ ์ „์‹  ๋ชจ์…˜์œผ๋กœ ์ƒˆ๋Š” ๋ฌธ์ œ๊ฐ€ ์žˆ์—ˆ๊ณ ,
๋Œ€์‘์œผ๋กœ ๊ทธ๋Ÿฐ ๋‹จ์–ด๋ฅผ ์•„์˜ˆ ์ œ์™ธํ•˜๋Š” ํ•„ํ„ฐ(_filter_generic_motion_verbs)๋ฅผ
๋„ฃ์–ด๋‘” ์ƒํƒœ๋‹ค - ๋กœ๋ผ๊ฐ€ ์˜๋„ํ•œ ํšจ๊ณผ๊นŒ์ง€ ๊ฐ™์ด ์‚ฌ๋ผ์ง€๋Š” ์šฐํšŒ์ฑ…์ด๋‹ค.
์ด ๋ชจ๋“ˆ์€ ๋‹ค๋ฅธ ๋ฐฉํ–ฅ์œผ๋กœ ์ ‘๊ทผํ•œ๋‹ค: ํŠธ๋ฆฌ๊ฑฐ๋ฅผ '๋‚˜์ค‘์— ๋ง๋ถ™์ผ ํ† ํฐ'์ด ์•„๋‹ˆ๋ผ
'ํ”„๋กฌํ”„ํŠธ๋ฅผ ์“ฐ๊ธฐ ์ „์— LLM์—๊ฒŒ ์ค„ ์žฌ๋ฃŒ'๋กœ ๋ณธ๋‹ค. LoRA Manager๊ฐ€ ํŒŒ์ผ ์˜†์—
๋งŒ๋“ค์–ด๋‘๋Š” `<์ด๋ฆ„>.metadata.json` ์‚ฌ์ด๋“œ์นด์— ์ด๋ฏธ ๋‹ค์Œ์ด ๋‹ค ๋“ค์–ด์žˆ๋‹ค.
- model_name / civitai.model.name : ์ด ๋กœ๋ผ์˜ ์ด๋ฆ„
- modelDescription : ์ž์—ฐ์–ด ์„ค๋ช…(HTML)
- tags / civitai.model.tags : ๋ถ„๋ฅ˜ ํƒœ๊ทธ
- civitai.trainedWords : ํ•™์Šต ํŠธ๋ฆฌ๊ฑฐ. ๋‹จ์–ด์ธ ๊ฒฝ์šฐ๋„ ์žˆ์ง€๋งŒ
์ƒ๋‹น์ˆ˜๋Š” ํ•™์Šต ์บก์…˜ ๋ฌธ์žฅ ๊ทธ๋Œ€๋กœ๋‹ค
(์˜ˆ: "She pulls down her pants revealing a penis.")
- base_model : Wan 2.1/2.2, i2v/t2v ๊ตฌ๋ถ„
๋กœ๋ผ๋Š” ํ•™์Šต ์บก์…˜ ๋ถ„ํฌ์™€ ๋‹ฎ์€ ํ”„๋กฌํ”„ํŠธ์—์„œ ๊ฐ€์žฅ ์ž˜ ๋ฐœํ™”ํ•˜๋ฏ€๋กœ, ์ด ์žฌ๋ฃŒ๋ฅผ
LLM์— ๋จผ์ € ์ฃผ๋ฉด ํŠธ๋ฆฌ๊ฑฐ๋ฅผ ๋ฌธ์žฅ ์•ˆ์— ๋ฌธ๋ฒ•์ ์œผ๋กœ ๋…น์ธ ํ”„๋กฌํ”„ํŠธ๋ฅผ ์“ธ ์ˆ˜ ์žˆ๋‹ค.
trainedWords 3๋ถ„๋ฅ˜ (`classify_trained_words` ์ฐธ๊ณ ):
hard : ๊ณต๋ฐฑ ์—†๊ณ  ์ˆซ์ž๋‚˜ ๋Œ€๋ฌธ์ž๊ฐ€ ์„ž์ธ ํ•™์Šต ์‹๋ณ„์ž. LLM์ด "๊ณ ์ณ์ฃผ๋ฉด"
๋กœ๋ผ๊ฐ€ ์•ˆ ๊ฑธ๋ฆฌ๋ฏ€๋กœ ์›๋ฌธ ๊ทธ๋Œ€๋กœ ๋‚˜์™€์•ผ ํ•œ๋‹ค (์˜ˆ: `4n4lfi5tin6`,
`PENISLORA`). ์ถœ๋ ฅ ๊ฒ€์ฆ ๋Œ€์ƒ.
soft : ๊ณต๋ฐฑ ์—†๋Š” ํ‰๋ฒ”ํ•œ ์†Œ๋ฌธ์ž ์˜๋‹จ์–ด (์˜ˆ: `bounce`, `penis`).
๊ฐ•์ œํ•˜์ง€ ์•Š๋Š”๋‹ค - ๊ฐ•์ œ ์‚ฝ์ž…์ด ๋ฐ”๋กœ ์˜ˆ์ „ ๋ฒ„๊ทธ์˜ ์›์ธ์ด์—ˆ๋‹ค.
๋ฌธ๋งฅ์— ๋งž์„ ๋•Œ ๋ชธ ๋ถ€์œ„์— ๋ถ™์—ฌ ์“ฐ๋ผ๊ณ  '๊ถŒ๊ณ '๋งŒ ํ•œ๋‹ค.
caption: ๊ณต๋ฐฑ์ด ์žˆ๋Š” ๊ตฌ/๋ฌธ์žฅ. ํ•™์Šต ์บก์…˜ ์˜ˆ์‹œ๋กœ๋งŒ ์ค€๋‹ค(๋ฌธ์ฒด ์ฐธ๊ณ ์šฉ).
"""
import json
import os
import re
_HTML_TAG_RE = re.compile(r"<[^>]+>")
_WS_RE = re.compile(r"\s+")
# ์„ธ์…˜ ๋‚ด ํ”„๋กœํ•„ ์บ์‹œ. ํ‚ค๋Š” ๋กœ๋ผ ํŒŒ์ผ์˜ (๊ฒฝ๋กœ, size, mtime).
_PROFILE_CACHE = {}
# LLM ์ปจํ…์ŠคํŠธ๋ฅผ ์žก์•„๋จน์ง€ ์•Š๋„๋ก ์„ค๋ช…์€ ์ž˜๋ผ์„œ ์ค€๋‹ค. ๋กœ๋ผ ์„ค๋ช…๋ฌธ์€ ๋ณดํ†ต
# ์•ž๋ถ€๋ถ„์— "๋ฌด์—‡์„ ํ•˜๋Š” ๋กœ๋ผ์ธ์ง€"๊ฐ€ ์žˆ๊ณ  ๋’ค๋Š” ํ•™์Šต ์„ค์ •/ํ›„์› ๋งํฌ๋‹ค.
MAX_DESC_CHARS = 400
MAX_CAPTION_EXAMPLES = 2
def _strip_html(s):
if not s:
return ""
s = re.sub(r"<br\s*/?>", " ", s, flags=re.IGNORECASE)
s = re.sub(r"</p>", " ", s, flags=re.IGNORECASE)
s = _HTML_TAG_RE.sub("", s)
s = (s.replace("&nbsp;", " ").replace("&amp;", "&")
.replace("&lt;", "<").replace("&gt;", ">").replace("&quot;", '"'))
return _WS_RE.sub(" ", s).strip()
def _truncate(s, limit):
if not s or len(s) <= limit:
return s
cut = s[:limit]
# ๋ฌธ์žฅ ์ค‘๊ฐ„์—์„œ ์ž๋ฅด์ง€ ์•Š๋„๋ก ๋งˆ์ง€๋ง‰ ๋ฌธ์žฅ๋ถ€ํ˜ธ๊นŒ์ง€๋งŒ.
for sep in (". ", "! ", "? "):
idx = cut.rfind(sep)
if idx > limit * 0.5:
return cut[:idx + 1]
return cut.rstrip() + "..."
def is_hard_token(word):
"""LLM์ด ์ฒ ์ž๋ฅผ ๋ฐ”๊พธ๋ฉด ์•ˆ ๋˜๋Š” ํ•™์Šต ์‹๋ณ„์ž์ธ๊ฐ€.
๊ณต๋ฐฑ์ด ์—†๊ณ (=๋‹จ์ผ ํ† ํฐ) ์ˆซ์ž๋‚˜ ๋Œ€๋ฌธ์ž๊ฐ€ ์„ž์—ฌ์žˆ์œผ๋ฉด ํ•™์Šต ์‹๋ณ„์ž๋กœ ๋ณธ๋‹ค.
`4n4lfi5tin6`, `PENISLORA`, `az420` -> True
`bounce`, `penis`, `swing` -> False (ํ‰๋ฒ”ํ•œ ์†Œ๋ฌธ์ž ์˜๋‹จ์–ด)
"""
w = word.strip()
if not w or _WS_RE.search(w):
return False
return any(c.isdigit() for c in w) or any(c.isupper() for c in w)
def classify_trained_words(words):
"""trainedWords ๋ฆฌ์ŠคํŠธ๋ฅผ (hard, soft, captions)๋กœ ๋‚˜๋ˆˆ๋‹ค."""
hard, soft, captions = [], [], []
for raw in words or []:
w = str(raw).strip().strip(",")
if not w:
continue
if _WS_RE.search(w):
captions.append(w)
elif is_hard_token(w):
hard.append(w)
else:
soft.append(w)
# ์ˆœ์„œ ์œ ์ง€ ์ค‘๋ณต ์ œ๊ฑฐ
return (list(dict.fromkeys(hard)), list(dict.fromkeys(soft)),
list(dict.fromkeys(captions)))
def _sidecar_path(lora_path):
"""LoRA Manager๊ฐ€ ๋งŒ๋“œ๋Š” `<ํ™•์žฅ์ž ๋—€ ์ด๋ฆ„>.metadata.json`."""
return os.path.splitext(lora_path)[0] + ".metadata.json"
def _read_sidecar(lora_path):
p = _sidecar_path(lora_path)
if not os.path.exists(p):
return None
try:
with open(p, "r", encoding="utf-8") as f:
return json.load(f)
except (OSError, ValueError, json.JSONDecodeError) as e:
print(f"โš ๏ธ [LoraProfile] ์‚ฌ์ด๋“œ์นด ํŒŒ์‹ฑ ์‹คํŒจ({os.path.basename(p)}): "
f"{type(e).__name__}: {e}")
return None
def _resolve_lora_path(lora_name):
try:
import folder_paths
return folder_paths.get_full_path("loras", lora_name)
except Exception:
return lora_name if os.path.exists(lora_name) else None
def build_profile_from_sidecar(data, lora_name, lora_path):
"""์‚ฌ์ด๋“œ์นด dict -> ํ”„๋กœํ•„ dict. ์‚ฌ์ด๋“œ์นด๊ฐ€ ์—†์œผ๋ฉด ํ˜ธ์ถœํ•˜์ง€ ์•Š๋Š”๋‹ค."""
civitai = data.get("civitai") or {}
model = civitai.get("model") or {}
name = (data.get("model_name") or model.get("name")
or os.path.splitext(os.path.basename(lora_name))[0])
desc = _strip_html(data.get("modelDescription") or model.get("description") or "")
tags = data.get("tags") or model.get("tags") or []
if not isinstance(tags, list):
tags = []
trained = civitai.get("trainedWords") or []
if not isinstance(trained, list):
trained = [trained]
hard, soft, captions = classify_trained_words(trained)
return {
"lora_name": lora_name,
"file": os.path.basename(lora_name),
"display_name": str(name).strip(),
"description": _truncate(desc, MAX_DESC_CHARS),
"tags": [str(t).strip() for t in tags if str(t).strip()],
"base_model": str(data.get("base_model") or civitai.get("baseModel") or "").strip(),
"hard_tokens": hard,
"soft_words": soft,
"captions": captions[:MAX_CAPTION_EXAMPLES],
"source": "sidecar",
}
def load_profile(lora_name, fallback_triggers=None):
"""๋กœ๋ผ ์ด๋ฆ„(ComfyUI ์ƒ๋Œ€๊ฒฝ๋กœ) -> ํ”„๋กœํ•„ dict. ์‹คํŒจ ์‹œ None.
์‚ฌ์ด๋“œ์นด๊ฐ€ ์—†์œผ๋ฉด `fallback_triggers`(๊ธฐ์กด get_triggers ๊ฒฝ๋กœ์˜ ๊ฒฐ๊ณผ)๋กœ
์ตœ์†Œ ํ”„๋กœํ•„์ด๋ผ๋„ ๋งŒ๋“ ๋‹ค - ๊ฐœ์ธ ํ•™์Šต ๋กœ๋ผ์ฒ˜๋Ÿผ civitai์— ์—†๋Š” ๊ฒฝ์šฐ.
`fallback_triggers`๋Š” ๋ฆฌ์ŠคํŠธ ๋˜๋Š” "ํ˜ธ์ถœํ•˜๋ฉด ๋ฆฌ์ŠคํŠธ๋ฅผ ์ฃผ๋Š” ํ•จ์ˆ˜"๋ฅผ ๋ฐ›๋Š”๋‹ค.
๊ธฐ์กด ์กฐํšŒ ๊ฒฝ๋กœ๋Š” sha256 ํ•ด์‹œ(์ˆ˜ GB ํŒŒ์ผ) + ๋„คํŠธ์›Œํฌ ํ˜ธ์ถœ๊นŒ์ง€ ํ•  ์ˆ˜ ์žˆ์–ด์„œ,
์‚ฌ์ด๋“œ์นด๊ฐ€ ์žˆ๋Š” 99.5%์˜ ๊ฒฝ์šฐ์—” ์•„์˜ˆ ํ˜ธ์ถœ๋˜์ง€ ์•Š๋„๋ก ์ง€์—ฐ ํ‰๊ฐ€ํ•œ๋‹ค.
"""
if not lora_name or lora_name == "None":
return None
path = _resolve_lora_path(lora_name)
cache_key = lora_name
if path and os.path.exists(path):
try:
st = os.stat(path)
cache_key = f"{lora_name}::{st.st_size}::{int(st.st_mtime)}"
except OSError:
pass
if cache_key in _PROFILE_CACHE:
return _PROFILE_CACHE[cache_key]
profile = None
if path:
data = _read_sidecar(path)
if data:
profile = build_profile_from_sidecar(data, lora_name, path)
if profile is None:
# ์‚ฌ์ด๋“œ์นด ์—†์Œ - ๊ธฐ์กด ํŠธ๋ฆฌ๊ฑฐ ์กฐํšŒ ๊ฒฐ๊ณผ๋กœ ์ตœ์†Œ ํ”„๋กœํ•„.
fb = fallback_triggers() if callable(fallback_triggers) else fallback_triggers
hard, soft, captions = classify_trained_words(fb or [])
if not (hard or soft or captions):
return None
profile = {
"lora_name": lora_name,
"file": os.path.basename(lora_name),
"display_name": os.path.splitext(os.path.basename(lora_name))[0],
"description": "",
"tags": [],
"base_model": "",
"hard_tokens": hard,
"soft_words": soft,
"captions": captions[:MAX_CAPTION_EXAMPLES],
"source": "triggers-only",
}
_PROFILE_CACHE[cache_key] = profile
return profile
def format_profiles_for_llm(profiles, indent=" "):
"""ํ”„๋กœํ•„ ๋ชฉ๋ก -> LLM ํ”„๋กฌํ”„ํŠธ์— ๋„ฃ์„ ํ…์ŠคํŠธ ๋ธ”๋ก.
ํ•œ ๋กœ๋ผ๋‹น 4์ค„ ์ด๋‚ด๋กœ ์••์ถ•ํ•œ๋‹ค. ๋กœ๋ผ๊ฐ€ 4๊ฐœ๊นŒ์ง€ ๊ฑธ๋ฆด ์ˆ˜ ์žˆ๊ณ  ํด๋ฆฝ๋„ 4๊ฐœ๋ผ
์žฅํ™ฉํ•˜๋ฉด ์‹œ์Šคํ…œ ํ”„๋กฌํ”„ํŠธ์˜ ๋‹ค๋ฅธ ๊ทœ์น™์ด ๋ฌปํžŒ๋‹ค.
"""
if not profiles:
return ""
lines = []
for p in profiles:
head = f"{indent}- {p['display_name']}"
if p.get("tags"):
head += f" [tags: {', '.join(p['tags'][:5])}]"
lines.append(head)
if p.get("description"):
lines.append(f"{indent} what it does: {p['description']}")
if p.get("hard_tokens"):
lines.append(f"{indent} MUST APPEAR VERBATIM: "
+ ", ".join(f'"{t}"' for t in p["hard_tokens"]))
if p.get("soft_words"):
lines.append(f"{indent} vocabulary it was trained on (use only where it "
f"genuinely fits, anchored to the body part): "
+ ", ".join(p["soft_words"]))
for cap in p.get("captions", []):
lines.append(f'{indent} training caption example: "{cap}"')
return "\n".join(lines)
def collect_hard_tokens(profiles):
out = []
for p in profiles or []:
out.extend(p.get("hard_tokens") or [])
return list(dict.fromkeys(out))
def missing_hard_tokens(text, profiles):
"""์ƒ์„ฑ๋œ ํ”„๋กฌํ”„ํŠธ์— ๋น ์ง„ hard token ๋ชฉ๋ก.
LLM์€ `4n4lfi5tin6` ๊ฐ™์€ ํ† ํฐ์„ "์ •์ƒ ์ฒ ์ž๋กœ ๊ณ ์ณ์ฃผ๋Š”" ๊ฒฝํ–ฅ์ด ์žˆ์–ด์„œ
(๊ทธ ์ˆœ๊ฐ„ ๋กœ๋ผ๊ฐ€ ์•ˆ ๊ฑธ๋ฆฐ๋‹ค) ๋Œ€์†Œ๋ฌธ์ž ๋ฌด์‹œํ•˜๊ณ  ์กด์žฌ๋งŒ ํ™•์ธํ•œ๋‹ค.
"""
low = (text or "").lower()
return [t for t in collect_hard_tokens(profiles) if t.lower() not in low]
if __name__ == "__main__":
# ์‹ค์ œ ๋กœ๋ผ ํด๋”์— ๋Œ€๊ณ  ๋Œ๋ ค๋ณด๋Š” ์ž์ฒด ์ ๊ฒ€.
import glob
import sys
# ComfyUI ์ฝ˜์†”์€ UTF-8์ด์ง€๋งŒ ๋งจ์†์œผ๋กœ ๋Œ๋ฆด ๋• cp949๋ผ ์ด๋ชจ์ง€ print๊ฐ€ ์ฃฝ๋Š”๋‹ค.
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except (AttributeError, OSError):
pass
root = sys.argv[1] if len(sys.argv) > 1 else "."
files = sorted(glob.glob(os.path.join(root, "*.safetensors")))
print(f"safetensors {len(files)}๊ฐœ ๊ฒ€์‚ฌ\n")
stats = {"sidecar": 0, "none": 0, "hard": 0, "soft": 0, "caption": 0, "desc": 0}
samples = []
for f in files:
data = _read_sidecar(f)
if not data:
stats["none"] += 1
continue
p = build_profile_from_sidecar(data, os.path.basename(f), f)
stats["sidecar"] += 1
if p["hard_tokens"]:
stats["hard"] += 1
if p["soft_words"]:
stats["soft"] += 1
if p["captions"]:
stats["caption"] += 1
if p["description"]:
stats["desc"] += 1
if p["hard_tokens"] and len(samples) < 5:
samples.append(p)
print(f"์‚ฌ์ด๋“œ์นด ์žˆ์Œ: {stats['sidecar']} / ์—†์Œ: {stats['none']}")
print(f" ์„ค๋ช… ์žˆ์Œ: {stats['desc']}")
print(f" hard token ์žˆ์Œ: {stats['hard']}")
print(f" soft word ์žˆ์Œ: {stats['soft']}")
print(f" caption ์˜ˆ์‹œ ์žˆ์Œ: {stats['caption']}\n")
for p in samples:
print("=" * 60)
print(format_profiles_for_llm([p]))