# -*- coding: utf-8 -*- # 2026-10-02 PD 지시(자율 반복) — 합격본(dataset.json)으로 FR 전용 아이콘 LoRA 를 학습한다. 학습을 거듭할수록 합격본이 늘고, 글자만으로 원하는 풍이 나오게 하는 것이 목표. # python comfy_anfg_train.py <버전 이름 예: v1> [steps=800] [PD 합격본 반복 배수=2] # 구성: 기반 mobaGameEquipment_v10 위에 ANFG LoRA(0.8)를 얹은 모델을 학습 대상으로 넣는다 → 새 LoRA 는 "ANFG 풍에서 FR 합격본 쪽으로의 차이"만 배운다. # (쓸 때도 ANFG LoRA 와 함께 건다.) 캡션 = "fricon, game icon institute, game icon, ahfg, <묘사>, black background". # 학습 노드 구성 · 입력 이름은 comfy_moba_lora_train.py(2026-10-02) · comfy_lora_train.py(2026-09-25 실측)와 같다. import io, json, os, shutil, sys, time, uuid, glob, urllib.request from PIL import Image, ImageOps import icon_dataset HOST = "http://127.0.0.1:8000" VER = sys.argv[1] STEPS = int(sys.argv[2]) if len(sys.argv) > 2 else 800 PD_REPEAT = int(sys.argv[3]) if len(sys.argv) > 3 else 2 NAME = "FR_anfg_%s" % VER DS_NAME = "fr_anfg_%s" % VER DS = os.path.join(r"C:\Comfyui\input", DS_NAME) LORA_OUT = r"C:\Comfyui\output\loras" LORA_DST = os.path.join(r"C:\Comfyui\models\loras", NAME + ".safetensors") CKPT = "mobaGameEquipment_v10.safetensors" BASE_LORA, BASE_LORA_STRENGTH = "giI_anfg_v3_000018.safetensors", 0.8 TRIGGER = "fricon, game icon institute, game icon, ahfg" RES = 512 LR, RANK, BATCH, SEED = 2e-4, 16, 2, 11 # a11 검토(v3): 글자만 생성이 "화면 가득한 금속 원반"으로 무너졌다. 원인으로 본 것 — ① 학습 그림이 캔버스의 82% 를 채워 여백을 못 배웠다 # ② 갑옷 · 목걸이 · 장화가 많아 둥근 덩어리로 쏠렸다 ③ PD 합격본 윤곽에서 나온 복제(genpd)가 섞였다 ④ 캡션에 구도 낱말이 없었다. # v4 부터: 그림을 줄여 여백을 넣고(MARGIN) · 종류당 장수를 제한하고(CAP) · genpd 유래를 빼고 · 캡션 앞에 생성 때와 같은 구도 낱말을 붙인다. MARGIN = float(os.environ.get("ANFG_TRAIN_MARGIN", "0.84")) # 82% 로 맞춘 그림을 다시 이 비율로 줄인다 → 물체가 캔버스의 약 69% CAP = int(os.environ.get("ANFG_TRAIN_CAP", "8")) # 종류당 최대 장수(PD 합격본은 제한 밖) COMPOSE = "single item, centered, full object in frame, wide margin" def kind_of(src): """원본 파일 이름(<회차>_<대상>_s..)에서 종류를 읽는다 — 대상이 '종류-길잡이' 꼴이면 앞부분""" import re m = re.match(r"^[a-z0-9]+_(.+)_s\d+_\d+\.png$", src) return m.group(1).split("-")[0] if m else "?" def select(data): """학습에 쓸 항목: PD 합격본 전부 + 나머지는 genpd 유래를 빼고 종류당 CAP 장(뒤 회차일수록 설정이 다듬어졌으므로 뒤에서부터 고른다)""" # 2026-10-03 PD 지적("원색 계열만 … 저급해 보인다") 뒤: 예전 합격본(금 + 적/청)으로 학습한 v4 가 원색으로 끌어당긴다. # ANFG_TRAIN_TAGS="a26,a27" 처럼 회차를 주면 그 회차(재질색 + 작은 강조색)의 합격본만으로 학습한다 — PD 가 예전에 고른 14장(원색 세트)도 뺀다. tags = [x for x in os.environ.get("ANFG_TRAIN_TAGS", "").split(",") if x] if tags: data = [e for e in data if e["src"].split("_")[0] in tags] picked = [e for e in data if e["source"] == "pd"] count = {} for e in reversed([e for e in data if e["source"] != "pd"]): if "-genpd" in e["src"]: continue k = kind_of(e["src"]) if count.get(k, 0) >= CAP: continue count[k] = count.get(k, 0) + 1 picked.append(e) print("학습 선택 %d / %d · 종류별 %s" % (len(picked), len(data), sorted(count.items(), key=lambda x: -x[1])), flush=True) return picked def build_dataset(): data = select(icon_dataset.load()) os.makedirs(DS, exist_ok=True) n = 0 for e in data: im = Image.open(os.path.join(icon_dataset.DS_DIR, e["file"])).convert("RGBA") side = max(1, int(RES * MARGIN)) small = im.resize((side, side), Image.LANCZOS) im = Image.new("RGBA", (RES, RES), (0, 0, 0, 0)); im.paste(small, ((RES - side) // 2, (RES - side) // 2), small) base = os.path.splitext(e["file"])[0] # PD 가 직접 고른 그림은 여러 번 넣어 비중을 높인다 # 검토에서 "최상급"으로 뽑힌 그림(best_*.json → dataset.json 의 best 표시)은 두 번 넣는다(장식 밀도 · 붓결이 좋은 쪽으로 끌기 위해) reps = PD_REPEAT if e["source"] == "pd" else (2 if e.get("best") else 1) for r in range(reps): for tag, v in (("a", im), ("b", ImageOps.mirror(im))): bg = Image.new("RGB", (RES, RES), (0, 0, 0)); bg.paste(v, (0, 0), v) stem = "%s_%d%s" % (base, r, tag) bg.save(os.path.join(DS, stem + ".png")) with io.open(os.path.join(DS, stem + ".txt"), "w", encoding="utf-8") as f: f.write("%s, %s, %s, black background" % (TRIGGER, COMPOSE, e["caption"])) n += 1 print("dataset %s: %d images (원본 %d)" % (DS, n, len(data)), flush=True) return n def get(path): return json.load(urllib.request.urlopen(HOST + path, timeout=120)) def train(): wf = { "1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": CKPT}}, "10": {"class_type": "LoraLoader", "inputs": {"model": ["1", 0], "clip": ["1", 1], "lora_name": BASE_LORA, "strength_model": BASE_LORA_STRENGTH, "strength_clip": BASE_LORA_STRENGTH}}, "2": {"class_type": "LoadImageTextDataSetFromFolder", "inputs": {"folder": DS_NAME}}, "3": {"class_type": "MakeTrainingDataset", "inputs": {"images": ["2", 0], "vae": ["1", 2], "clip": ["10", 1], "texts": ["2", 1]}}, "4": {"class_type": "TrainLoraNode", "inputs": { "model": ["10", 0], "latents": ["3", 0], "positive": ["3", 1], "batch_size": BATCH, "grad_accumulation_steps": 1, "steps": STEPS, "learning_rate": LR, "rank": RANK, "optimizer": "AdamW", "loss_function": "MSE", "seed": SEED, "training_dtype": "bf16", "lora_dtype": "bf16", "quantized_backward": False, "algorithm": "LoRA", "gradient_checkpointing": True, "checkpoint_depth": 1, "offloading": False, "existing_lora": "[None]", "bucket_mode": False, "bypass_mode": False}}, "5": {"class_type": "SaveLoRA", "inputs": {"lora": ["4", 0], "prefix": "loras/" + NAME, "steps": ["4", 2]}}, "6": {"class_type": "LossGraphNode", "inputs": {"loss": ["4", 1], "filename_prefix": NAME + "_loss"}}, } req = urllib.request.Request(HOST + "/prompt", data=json.dumps({"prompt": wf, "client_id": str(uuid.uuid4())}).encode(), headers={"Content-Type": "application/json"}) try: r = json.load(urllib.request.urlopen(req, timeout=60)) except urllib.error.HTTPError as e: print("PROMPT REJECTED:", e.read().decode(errors="replace")[:2000]); return None if r.get("node_errors"): print("NODE ERRORS:", json.dumps(r["node_errors"], ensure_ascii=False)[:2000]); return None pid = r["prompt_id"] print("queued %s · steps %d · batch %d · lr %s · rank %d" % (pid, STEPS, BATCH, LR, RANK), flush=True) t0 = time.time() while time.time() - t0 < 7200: time.sleep(20) h = get("/history/" + pid) if pid in h: st = h[pid].get("status", {}) if st.get("status_str") == "error": for m in st.get("messages", []): if m[0] == "execution_error": print("TRAIN ERROR:", m[1].get("node_type"), m[1].get("exception_message", "")[:1500]) print("\n".join(m[1].get("traceback", [])[-6:])[:2000]) return None print("done in %.0fs" % (time.time() - t0), flush=True) return h[pid]["outputs"] print("TIMEOUT"); return None if __name__ == "__main__": if os.path.isdir(DS) and os.listdir(DS): print("데이터셋 폴더가 이미 있다(버전 이름을 바꿔 다시 실행):", DS); sys.exit(1) build_dataset() if train() is None: sys.exit(1) files = sorted(glob.glob(os.path.join(LORA_OUT, NAME + "*.safetensors")), key=os.path.getmtime) if not files: print("no lora file in", LORA_OUT); sys.exit(1) shutil.copy2(files[-1], LORA_DST) print("LORA: %s -> %s (%.1f MB)" % (files[-1], LORA_DST, os.path.getsize(LORA_DST) / 2**20))