docs(BT49-FirstRising): 장비 아이콘 생성 체계 — 회차 a1~a28 기록 · 생성/검토/학습 도구 · 색 개선(부위별 재질 + 작은 강조색) · 최상급 묶음 · 세션 인수인계서

- 공유/기획/FirstRising/아이템/아이콘_생성_체계.md: 목표 풍 · 합격 기준 · 반복 순서 · 회차 기록 · 설정 · 결론
- 도구: comfy_moba_anfg.py(생성) · icon_shapes.py · icon_guide_bootstrap.py · icon_make.py · icon_review_sheet.py · icon_dataset.py · comfy_anfg_train.py · icon_pack.py 등
- 결과: moba_anfg/best_color(색 개선 최상급 32) · best_v4e(예전 색 46) · approved(PD 합격 15) · 선별 목록 json · 비교 시트
- 생성 원본 · 데이터셋 그림 · 초벌(약 2.1 GB)은 용량 때문에 올리지 않음(작업 PC 에만 있음)
- 대화로그 2026-10-02 · 세션 인수인계 2026-10-03 · PD 지시 로그 BT49(보류)
- 감사 생략(BT19-Org) · FR 프로젝트 반입 없음

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
깃 관리자 2026-10-02 22:18:58 +09:00
parent 97f140c02b
commit 36c406b3c5
178 changed files with 7586 additions and 0 deletions

View File

@ -33,6 +33,7 @@ C3·C13 위반에 해당. **즉시 자진 보고 후 소급 등록**.
| # | 일시 | 지시 요지 | 처리 상태 | 산출물 경로 | 중단 사유 | 사후 조치 | | # | 일시 | 지시 요지 | 처리 상태 | 산출물 경로 | 중단 사유 | 사후 조치 |
|---|------|----------|----------|-----------|----------|----------| |---|------|----------|----------|-----------|----------|----------|
| BT49-FirstRising | 2026-10-02 | **장비 아이콘 생성 체계 구축 (ComfyUI · 투명 배경) — 원하는 장비 아이콘을 자유롭게 생성할 수 있을 때까지 생성 · 검토 · 학습을 자율 반복** + (10-03) **원색 계열만 써서 저급해 보인다 → 색감 개선** (원문은 대화로그) | **보류(10-03 PD "이 세션의 작업은 여기서 마무리하고, 추후 다시 진행")** — 회차 a1~a28 · 자체 LoRA v1~v6. 되는 방식 = 윤곽 길잡이 + 부위별 재질 · 작은 강조색 묘사(검토 평가: 길잡이 방식 6.5~7/10 · 레퍼런스 색감 대비 6/10 · 글자만 4.5/10). 정식 경로 = 글자 → SDXL 초벌 → 눈으로 고른 윤곽 길잡이 → 본 생성(`icon_make.py`). 리소스 후보: 색 개선 최상급 32장(21종) `moba_anfg/best_color/` · 예전 색 최상급 46장 · 합격본 166장 · 데이터셋 462장. 결정 대기: 색 개선 방향 확인 · FR 반입 · SDXL 전환(GPU 업그레이드 뒤). 미해결: 뿔피리 · 석궁류 · 손그림 질감. FR 반입 없음. 문서 `공유/기획/FirstRising/아이템/아이콘_생성_체계.md` · 인수인계 `공유/대화로그/FirstRising/2026-10-03_세션인수인계.md` |
| BT48-FirstRising | 2026-10-01 | **아이템 드랍 · 아이템 · 능력치 시스템 구현 (디아블로식 파밍) — 몬스터 처치 시 드롭, 인벤토리에서 옵션 확인, 완료까지 중단 없이 자율 반복** (BT47 초안 v1 확정 기준 · 원문은 대화로그) | **완료 · FR `bt/work` `dc45353d72` push(10-01 22:59)** — 후속 3건 반영(드롭률 10% · 예전 등급 옵션 표시/적용 제거 + 기존 장비 변환 · 활 기본 투사체 수 1) · 3차 검증 Play 오류 0 · — 처치 → 드롭(등급 색 기둥 + 한글 이름표) → 습득 → 인벤토리 · 정보 창(고정1 · 옵션) → 8부위 장착 → 능력치 엔티티 반영(방어 v2 · 신성 · 관통 · 고정 피해 · 저항 · 회피 · 막기). 보류: 공격 속도 · 사거리 · 투사체 수 · 범위 피해 · 스킬 레벨 · 상태이상 · 흡혈 · 획득량 · 조건부 옵션(스킬 시스템 수정 필요) | FR `Assets/BurningTimes/Visual/Scripts/TV{ItemData,GlobalValue,ItemDatabase,ItemGenerator,LootDrops,ItemStats,ItemStatSync,ItemText}.cs` · `Resources/BT/*.asset` · 원본 9곳 · 인계서 `공유/대화로그/FirstRising/2026-10-01_세션인수인계.md` · 검증 `몬스터보스/tools/unity_eval/bt_evals_1001/` | — | — | | BT48-FirstRising | 2026-10-01 | **아이템 드랍 · 아이템 · 능력치 시스템 구현 (디아블로식 파밍) — 몬스터 처치 시 드롭, 인벤토리에서 옵션 확인, 완료까지 중단 없이 자율 반복** (BT47 초안 v1 확정 기준 · 원문은 대화로그) | **완료 · FR `bt/work` `dc45353d72` push(10-01 22:59)** — 후속 3건 반영(드롭률 10% · 예전 등급 옵션 표시/적용 제거 + 기존 장비 변환 · 활 기본 투사체 수 1) · 3차 검증 Play 오류 0 · — 처치 → 드롭(등급 색 기둥 + 한글 이름표) → 습득 → 인벤토리 · 정보 창(고정1 · 옵션) → 8부위 장착 → 능력치 엔티티 반영(방어 v2 · 신성 · 관통 · 고정 피해 · 저항 · 회피 · 막기). 보류: 공격 속도 · 사거리 · 투사체 수 · 범위 피해 · 스킬 레벨 · 상태이상 · 흡혈 · 획득량 · 조건부 옵션(스킬 시스템 수정 필요) | FR `Assets/BurningTimes/Visual/Scripts/TV{ItemData,GlobalValue,ItemDatabase,ItemGenerator,LootDrops,ItemStats,ItemStatSync,ItemText}.cs` · `Resources/BT/*.asset` · 원본 9곳 · 인계서 `공유/대화로그/FirstRising/2026-10-01_세션인수인계.md` · 검증 `몬스터보스/tools/unity_eval/bt_evals_1001/` | — | — |
| BT47-FirstRising | 2026-10-01 | **아이템 드랍 시스템 초안 설계** — 등급 10 · 부위 8 · 고정 스텟 + 랜덤 옵션 · 몬스터 레벨→드랍 범위 · 획득 찬스→상위 등급. 능력치 시스템 변경 규모 병렬 점검(원문은 대화로그) | **초안 v0 + PPT 21장 제출(10-01) · PD 결정 대기 8건(Q14~Q21)** — 판정 [실측]: 대대적 개편 아님, 중간 규모 확장. PD 확정 13건(신성 = 방어 무시 순수 피해 · 공격 속도 = 무기 스킬 쿨타임 · 관통/고정 피해/막기 정의 · 보조 장비 8종 · 반지 1/목걸이 1 · 등급 = 성능만) 반영. 방어력 공식 v2(로그형 단일 곡선, 상한 80% 하나) PD "우선 이대로" | `공유/기획/FirstRising/시스템기획/2026-10-01_FR_아이템드랍시스템_초안_v0.md` · `…_초안_v0.pptx` · `tools/build_item_drop_v0.py` | — | — | | BT47-FirstRising | 2026-10-01 | **아이템 드랍 시스템 초안 설계** — 등급 10 · 부위 8 · 고정 스텟 + 랜덤 옵션 · 몬스터 레벨→드랍 범위 · 획득 찬스→상위 등급. 능력치 시스템 변경 규모 병렬 점검(원문은 대화로그) | **초안 v0 + PPT 21장 제출(10-01) · PD 결정 대기 8건(Q14~Q21)** — 판정 [실측]: 대대적 개편 아님, 중간 규모 확장. PD 확정 13건(신성 = 방어 무시 순수 피해 · 공격 속도 = 무기 스킬 쿨타임 · 관통/고정 피해/막기 정의 · 보조 장비 8종 · 반지 1/목걸이 1 · 등급 = 성능만) 반영. 방어력 공식 v2(로그형 단일 곡선, 상한 80% 하나) PD "우선 이대로" | `공유/기획/FirstRising/시스템기획/2026-10-01_FR_아이템드랍시스템_초안_v0.md` · `…_초안_v0.pptx` · `tools/build_item_drop_v0.py` | — | — |
| BT46-Org | 2026-09-30 09:40 | **Claude 윈도우 앱이 쓰지 않는 동안 트레이에서 사라짐 — 원인 확인 · 조치** (원문은 대화로그) | **원인 확정 · 조치 준비 완료(09-30 10:00) · PD 실행 대기** — 원인 [실측 · 앱 로그 + 윈도우 이벤트 로그]: 앱이 업데이트를 받아 둔 채 10분쯤 쉬면 창 · 트레이를 내리고 윈도우에 강제 종료 + 새 버전 등록을 요청하는데, 09-23 02:56 부터 앱이 자기 프로세스를 작업 묶음에 넣으면서(`[windows-app-job] joined`) 윈도우가 앱을 닫지 못한다(AppXDeploymentServer 419 · 404 — 0x800700E9 / 0x8007001F). 그 전 18회는 2~40초 만에 재시작, 그 뒤 6회는 전부 실패. 실패하면 앱은 창 · 트레이 없이 숨어서 남고 아이콘을 눌러도 뜨지 않는다. 앱 내부 결함이라 직접 고칠 수 없음 → 감시 스크립트 작성 · 시험 모드 4가지 상황 통과. 작업 스케줄러 등록은 시스템 설정이라 PD 가 명령 1개로 실행 | `%LOCALAPPDATA%/ClaudeTrayWatchdog/`(watchdog.ps1 · run-hidden.vbs) · `공유/대화로그/조직운영/2026-09-30_Claude앱_트레이.md` | — | 등록 뒤 첫 실제 발생 때 동작 확인 필요(실제 끄고 켜기는 시험하지 않음 — 이 세션이 그 앱 안에서 돈다) · 근본 수정은 Anthropic 앱 업데이트 | | BT46-Org | 2026-09-30 09:40 | **Claude 윈도우 앱이 쓰지 않는 동안 트레이에서 사라짐 — 원인 확인 · 조치** (원문은 대화로그) | **원인 확정 · 조치 준비 완료(09-30 10:00) · PD 실행 대기** — 원인 [실측 · 앱 로그 + 윈도우 이벤트 로그]: 앱이 업데이트를 받아 둔 채 10분쯤 쉬면 창 · 트레이를 내리고 윈도우에 강제 종료 + 새 버전 등록을 요청하는데, 09-23 02:56 부터 앱이 자기 프로세스를 작업 묶음에 넣으면서(`[windows-app-job] joined`) 윈도우가 앱을 닫지 못한다(AppXDeploymentServer 419 · 404 — 0x800700E9 / 0x8007001F). 그 전 18회는 2~40초 만에 재시작, 그 뒤 6회는 전부 실패. 실패하면 앱은 창 · 트레이 없이 숨어서 남고 아이콘을 눌러도 뜨지 않는다. 앱 내부 결함이라 직접 고칠 수 없음 → 감시 스크립트 작성 · 시험 모드 4가지 상황 통과. 작업 스케줄러 등록은 시스템 설정이라 PD 가 명령 1개로 실행 | `%LOCALAPPDATA%/ClaudeTrayWatchdog/`(watchdog.ps1 · run-hidden.vbs) · `공유/대화로그/조직운영/2026-09-30_Claude앱_트레이.md` | — | 등록 뒤 첫 실제 발생 때 동작 확인 필요(실제 끄고 켜기는 시험하지 않음 — 이 세션이 그 앱 안에서 돈다) · 근본 수정은 Anthropic 앱 업데이트 |

View File

@ -0,0 +1,90 @@
# -*- coding: utf-8 -*-
# 2026-10-01 PD 지시 — "내가 원하는 스타일이 아니야. 다른 체크포인트로 제작을 시도해보자."
# 보유한 다른 체크포인트로 같은 대상 6종을 시험 생성해 한 장으로 모은다(풍 비교용).
# python comfy_alt_ckpt.py <출력폴더> <flux|noob|noob2|sd15rpg>
import io, json, os, sys, time, uuid, urllib.request, urllib.parse
from PIL import Image, ImageDraw
HOST = "http://127.0.0.1:8000"
SUBJECTS = [("sword", "a sword"), ("armor", "a breastplate armor"), ("gloves", "a pair of gauntlets"), ("ring", "a ring with a gemstone"), ("shield", "a shield"), ("bow", "a bow")]
# 목표 풍(PD 가 처음 고른 사이트 예시의 특징): 굵고 양식화된 손그림풍 MMO 아이콘 · 진한 채도 · 두꺼운 형태 · 발광 · 검은 배경
STYLE = "stylized fantasy MMORPG game item icon, hand-painted, bold thick chunky shapes, vibrant saturated colors, glowing magical accents, ornate, three-quarter view, centered single object, plain black background"
PRESETS = {
"flux": dict(ckpt="flux1CheckpointEasyTo_v10.safetensors", flux=True, size=768, steps=20, cfg=1.0, sampler="euler", scheduler="simple", neg="", prefix="", lora=None),
"noob": dict(ckpt="noobaiXLNAIXL_epsilonPred05Version.safetensors", flux=False, size=1024, steps=26, cfg=6.0, sampler="euler_ancestral", scheduler="normal",
neg="worst quality, low quality, blurry, text, watermark, person, 1girl, hands, photo", prefix="masterpiece, best quality, no humans, ", lora=None),
"noob2": dict(ckpt="catTowerNoobaiXL_chenkinnoobV11.safetensors", flux=False, size=1024, steps=26, cfg=6.0, sampler="euler_ancestral", scheduler="normal",
neg="worst quality, low quality, blurry, text, watermark, person, 1girl, hands, photo", prefix="masterpiece, best quality, no humans, ", lora=None),
"sd15rpg": dict(ckpt="difconsistencyCheckpoint_v10.safetensors", flux=False, size=512, steps=28, cfg=7.0, sampler="dpmpp_2m", scheduler="karras",
neg="worst quality, low quality, blurry, text, watermark, person, hands, photo, realistic", prefix="", lora=("GameItemUI_sd15.safetensors", 0.8)),
}
def post(path, data):
req = urllib.request.Request(HOST + path, data=json.dumps(data).encode("utf-8"), headers={"Content-Type": "application/json"})
return json.loads(urllib.request.urlopen(req, timeout=60).read())
def get(path):
return urllib.request.urlopen(HOST + path, timeout=300).read()
def workflow(p, prompt, seed):
wf = {"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": p["ckpt"]}}}
model, clip = ["1", 0], ["1", 1]
if p["lora"]:
wf["2"] = {"class_type": "LoraLoader", "inputs": {"model": model, "clip": clip, "lora_name": p["lora"][0], "strength_model": p["lora"][1], "strength_clip": p["lora"][1]}}
model, clip = ["2", 0], ["2", 1]
wf["4"] = {"class_type": "CLIPTextEncode", "inputs": {"clip": clip, "text": prompt}}
wf["5"] = {"class_type": "CLIPTextEncode", "inputs": {"clip": clip, "text": p["neg"]}}
pos = ["4", 0]
if p["flux"]:
wf["13"] = {"class_type": "FluxGuidance", "inputs": {"conditioning": ["4", 0], "guidance": 3.5}}
pos = ["13", 0]
wf["6"] = {"class_type": "EmptyLatentImage", "inputs": {"width": p["size"], "height": p["size"], "batch_size": 1}}
wf["7"] = {"class_type": "KSampler", "inputs": {"model": model, "positive": pos, "negative": ["5", 0], "latent_image": ["6", 0], "seed": seed, "steps": p["steps"], "cfg": p["cfg"], "sampler_name": p["sampler"], "scheduler": p["scheduler"], "denoise": 1.0}}
wf["8"] = {"class_type": "VAEDecode", "inputs": {"samples": ["7", 0], "vae": ["1", 2]}}
wf["9"] = {"class_type": "SaveImage", "inputs": {"images": ["8", 0], "filename_prefix": "alt_ckpt"}}
return wf
def run(p, prompt, seed):
pid = post("/prompt", {"prompt": workflow(p, prompt, seed), "client_id": str(uuid.uuid4())})["prompt_id"]
t0 = time.time()
while time.time() - t0 < 1500:
hist = json.loads(get("/history/" + pid))
if pid in hist:
st = hist[pid].get("status", {})
if st.get("status_str") == "error":
raise RuntimeError(json.dumps(st)[:900])
for node in hist[pid].get("outputs", {}).values():
for im in node.get("images", []):
q = urllib.parse.urlencode({"filename": im["filename"], "subfolder": im.get("subfolder", ""), "type": im.get("type", "output")})
return Image.open(io.BytesIO(get("/view?" + q))).convert("RGB")
time.sleep(2)
raise TimeoutError("comfy timeout")
def main():
out, name = sys.argv[1], sys.argv[2]
p = PRESETS[name]
os.makedirs(out, exist_ok=True)
cell, pad, label = 256, 6, 18
sheet = Image.new("RGB", (len(SUBJECTS) * (cell + pad) + pad, cell + pad * 2 + label), (40, 40, 40))
draw = ImageDraw.Draw(sheet)
for i, (sname, body) in enumerate(SUBJECTS):
prompt = "%s%s, %s" % (p["prefix"], body, STYLE)
t0 = time.time()
im = run(p, prompt, 7000 + i * 19)
print("%s %s %.1f초" % (name, sname, time.time() - t0)); sys.stdout.flush()
im.save(os.path.join(out, "%s_%s.png" % (name, sname)))
sheet.paste(im.resize((cell, cell), Image.LANCZOS), (pad + i * (cell + pad), label + pad))
draw.text((pad + i * (cell + pad) + 4, 3), "%s / %s" % (name, sname), fill=(240, 220, 160))
path = os.path.join(out, "%s_sheet.png" % name)
sheet.save(path)
print("sheet", path)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,143 @@
# -*- 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))

View File

@ -0,0 +1,131 @@
# -*- coding: utf-8 -*-
# 2026-10-01 PD 지시 — "game Icon Institute_anfg_v3-000018 로라를 적용해서 다시" · 트리거 "game icon institute, game icon, ahfg".
# 사이트 예시(직접 확인): 굵고 양식화된 손그림풍 MMO 아이콘 · 진한 채도 · 두꺼운 형태 · 발광 · 검은 배경.
# 앞선 결과가 얇고 사실적으로 나온 원인 = 기반 모델(제작자 권장 "游戏图标研究所 3.0" SD1.5 가 이 PC 에 없어 사실풍 difconsistency 로 대체).
# 여기서는 같은 기반에서 LoRA 를 더 세게 걸고(변형별 강도), 사실풍을 부정어로 누르고, 2배 다시 그리기를 빼서 LoRA 의 그림체를 최대한 살린다.
# python comfy_gii_style.py <출력폴더> <round 이름> <LoRA 강도> <clip skip(음수)> [rembg 0|1] [시드 수]
import io, json, os, sys, time, uuid, urllib.request, urllib.parse
from PIL import Image, ImageDraw
HOST = "http://127.0.0.1:8000"
CKPT = "difconsistencyCheckpoint_v10.safetensors"
LORA = "game Icon Institute_anfg_v3-000018.safetensors" # PD 가 loras 폴더에 넣어 둔 원래 이름의 파일
TRIGGER = "game icon institute, game icon, ahfg"
# 페이지의 부정어 "(easynegative:0.8), black, dark, Low resolution" + 사실풍 누르기
NEG = "black, dark, low resolution, worst quality, low quality, blurry, photo, photorealistic, realistic, 3d render, thin, text, watermark, signature, cropped, person, hands"
SUBJECTS = [
("sword", "sword"),
("blunt", "hammer, mace"),
("bow", "bow"),
("dagger", "dagger"),
("staff", "staff, gem"),
("magic_dagger", "dagger, glowing, gem"),
("armor", "armor"),
("belt", "belt"),
("gloves", "gloves, gauntlets"),
("boots", "boots"),
("ring", "ring, gem"),
("necklace", "necklace, gem"),
("shield", "shield"),
("quiver", "quiver, arrows"),
("spellbook", "book"),
("throwing", "shuriken"),
("orb", "orb, crystal, glowing"),
("offhand_sword", "short sword"),
]
def post(path, data):
req = urllib.request.Request(HOST + path, data=json.dumps(data).encode("utf-8"), headers={"Content-Type": "application/json"})
return json.loads(urllib.request.urlopen(req, timeout=60).read())
def get(path):
return urllib.request.urlopen(HOST + path, timeout=180).read()
def workflow(prompt, seed, strength, clip_skip, rembg):
wf = {
"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": CKPT}},
"2": {"class_type": "LoraLoader", "inputs": {"model": ["1", 0], "clip": ["1", 1], "lora_name": LORA, "strength_model": strength, "strength_clip": strength}},
"3": {"class_type": "CLIPSetLastLayer", "inputs": {"clip": ["2", 1], "stop_at_clip_layer": clip_skip}},
"4": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["3", 0], "text": prompt}},
"5": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["3", 0], "text": NEG}},
"6": {"class_type": "EmptyLatentImage", "inputs": {"width": 512, "height": 512, "batch_size": 1}},
"7": {"class_type": "KSampler", "inputs": {"model": ["2", 0], "positive": ["4", 0], "negative": ["5", 0], "latent_image": ["6", 0], "seed": seed, "steps": 28, "cfg": 7.0, "sampler_name": "euler_ancestral", "scheduler": "normal", "denoise": 1.0}},
"8": {"class_type": "VAEDecode", "inputs": {"samples": ["7", 0], "vae": ["1", 2]}},
}
if rembg:
wf["12"] = {"class_type": "InspyrenetRembg", "inputs": {"image": ["8", 0], "torchscript_jit": "default"}}
wf["9"] = {"class_type": "SaveImage", "inputs": {"images": ["12", 0], "filename_prefix": "gii_style"}}
else:
wf["9"] = {"class_type": "SaveImage", "inputs": {"images": ["8", 0], "filename_prefix": "gii_style"}}
return wf
def run(prompt, seed, strength, clip_skip, rembg):
cid = str(uuid.uuid4())
pid = post("/prompt", {"prompt": workflow(prompt, seed, strength, clip_skip, rembg), "client_id": cid})["prompt_id"]
t0 = time.time()
while time.time() - t0 < 900:
hist = json.loads(get("/history/" + pid))
if pid in hist:
st = hist[pid].get("status", {})
if st.get("status_str") == "error":
raise RuntimeError(json.dumps(st)[:800])
outs = hist[pid].get("outputs", {})
if outs:
for node in outs.values():
for im in node.get("images", []):
q = urllib.parse.urlencode({"filename": im["filename"], "subfolder": im.get("subfolder", ""), "type": im.get("type", "output")})
return Image.open(io.BytesIO(get("/view?" + q))).convert("RGBA")
time.sleep(1.2)
raise TimeoutError("comfy timeout")
def checker(size, n=16):
im = Image.new("RGB", (size, size), (58, 58, 58))
d = ImageDraw.Draw(im)
for y in range(0, size, n):
for x in range(0, size, n):
if (x // n + y // n) % 2:
d.rectangle([x, y, x + n - 1, y + n - 1], fill=(74, 74, 74))
return im
def main():
out, tag = sys.argv[1], sys.argv[2]
strength, clip_skip = float(sys.argv[3]), int(sys.argv[4])
rembg = int(sys.argv[5]) if len(sys.argv) > 5 else 0
seeds = int(sys.argv[6]) if len(sys.argv) > 6 else 2
lo = int(sys.argv[7]) if len(sys.argv) > 7 else 0
hi = int(sys.argv[8]) if len(sys.argv) > 8 else len(SUBJECTS)
os.makedirs(out, exist_ok=True)
subs = SUBJECTS[lo:hi]
cell, pad, label = 200, 4, 16
cols = 9
rows = (len(subs) + cols - 1) // cols
sheet = Image.new("RGB", (cols * (cell + pad) + pad, rows * (seeds * (cell + pad) + label) + pad), (40, 40, 40))
draw = ImageDraw.Draw(sheet)
for i, (name, body) in enumerate(subs):
prompt = "%s, %s, still life, black background" % (TRIGGER, body)
cx = pad + (i % cols) * (cell + pad)
cy = pad + (i // cols) * (seeds * (cell + pad) + label)
draw.text((cx + 3, cy + 2), name, fill=(240, 220, 160))
for j in range(seeds):
t0 = time.time()
im = run(prompt, 5000 + (lo + i) * 17 + j * 307, strength, clip_skip, rembg)
print("%s %s #%d %.1f초" % (tag, name, j, time.time() - t0)); sys.stdout.flush()
im.save(os.path.join(out, "%s_%s_%d.png" % (tag, name, j)))
bg = checker(cell) if rembg else Image.new("RGB", (cell, cell), (0, 0, 0))
small = im.resize((cell, cell), Image.LANCZOS)
bg.paste(small, (0, 0), small)
sheet.paste(bg, (cx, cy + label + j * (cell + pad)))
path = os.path.join(out, "%s_sheet.png" % tag)
sheet.save(path)
print("sheet", path)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,126 @@
# -*- coding: utf-8 -*-
# 2026-10-01 PD 지시 — 체크포인트 mobaGameEquipment_v10 으로 civitai 88908 "Moba game equipment icon" 풍의 아이콘을 만든다. 배경은 투명.
# 페이지 예시 그림의 설정(직접 확인): 프롬프트 "(masterpiece:1.2), (best quality:1.2), moba game icon, …" · 부정어 "lowres, bad anatomy, bad hands, text, error, …" ·
# DPM++ 2M Karras · 30 steps · CFG 7 · clip skip 2 · 권장 해상도 512 / 768. 트리거 = moba game icon.
# python comfy_moba.py <출력폴더> <round 이름> [크기=768] [시드 수=2] [시작] [끝] [rembg=1]
import io, json, os, sys, time, uuid, urllib.request, urllib.parse
from PIL import Image, ImageDraw
HOST = "http://127.0.0.1:8000"
CKPT = "mobaGameEquipment_v10.safetensors"
PREFIX = "(masterpiece:1.2), (best quality:1.2), moba game icon"
# 2차: 1차(768)에서 지팡이 · 목걸이 · 부츠 · 벨트가 인물로 나와 인물 부정어를 더했다
NEG = "lowres, bad anatomy, bad hands, text, error, missing fingers, extra digit, fewer digits, cropped, worst quality, low quality, normal quality, jpeg artifacts, signature, watermark, username, blurry, 1girl, 1boy, person, human, character, face, hair, breasts, body, wearing"
# FR 장비 전 부위(무기 6 · 방어구 4 · 장신구 2 · 보조 장비 6)
SUBJECTS = [
("sword", "sword"),
("blunt", "war hammer, mace"),
("bow", "bow"),
("dagger", "dagger"),
("staff", "magic staff"),
("magic_dagger", "magic dagger, glowing blade"),
("armor", "armor, breastplate"),
("belt", "belt"),
("gloves", "gauntlets, gloves"),
("boots", "boots"),
("ring", "ring"),
("necklace", "necklace, pendant"),
("shield", "shield"),
("quiver", "quiver with arrows"),
("spellbook", "spellbook, magic book"),
("throwing", "shuriken, throwing blade"),
("orb", "magic orb, crystal ball"),
("offhand_sword", "short sword"),
]
def post(path, data):
req = urllib.request.Request(HOST + path, data=json.dumps(data).encode("utf-8"), headers={"Content-Type": "application/json"})
return json.loads(urllib.request.urlopen(req, timeout=60).read())
def get(path):
return urllib.request.urlopen(HOST + path, timeout=300).read()
def workflow(prompt, seed, size, rembg):
wf = {
"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": CKPT}},
"3": {"class_type": "CLIPSetLastLayer", "inputs": {"clip": ["1", 1], "stop_at_clip_layer": -2}},
"4": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["3", 0], "text": prompt}},
"5": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["3", 0], "text": NEG}},
"6": {"class_type": "EmptyLatentImage", "inputs": {"width": size, "height": size, "batch_size": 1}},
"7": {"class_type": "KSampler", "inputs": {"model": ["1", 0], "positive": ["4", 0], "negative": ["5", 0], "latent_image": ["6", 0], "seed": seed, "steps": 30, "cfg": 7.0, "sampler_name": "dpmpp_2m", "scheduler": "karras", "denoise": 1.0}},
"8": {"class_type": "VAEDecode", "inputs": {"samples": ["7", 0], "vae": ["1", 2]}},
}
if rembg:
wf["12"] = {"class_type": "InspyrenetRembg", "inputs": {"image": ["8", 0], "torchscript_jit": "default"}}
wf["9"] = {"class_type": "SaveImage", "inputs": {"images": ["12", 0], "filename_prefix": "moba"}}
else:
wf["9"] = {"class_type": "SaveImage", "inputs": {"images": ["8", 0], "filename_prefix": "moba"}}
return wf
def run(prompt, seed, size, rembg):
pid = post("/prompt", {"prompt": workflow(prompt, seed, size, rembg), "client_id": str(uuid.uuid4())})["prompt_id"]
t0 = time.time()
while time.time() - t0 < 900:
hist = json.loads(get("/history/" + pid))
if pid in hist:
st = hist[pid].get("status", {})
if st.get("status_str") == "error":
raise RuntimeError(json.dumps(st)[:900])
for node in hist[pid].get("outputs", {}).values():
for im in node.get("images", []):
q = urllib.parse.urlencode({"filename": im["filename"], "subfolder": im.get("subfolder", ""), "type": im.get("type", "output")})
return Image.open(io.BytesIO(get("/view?" + q))).convert("RGBA")
time.sleep(1.2)
raise TimeoutError("comfy timeout")
def checker(size, n=16):
im = Image.new("RGB", (size, size), (58, 58, 58))
d = ImageDraw.Draw(im)
for y in range(0, size, n):
for x in range(0, size, n):
if (x // n + y // n) % 2:
d.rectangle([x, y, x + n - 1, y + n - 1], fill=(74, 74, 74))
return im
def main():
out, tag = sys.argv[1], sys.argv[2]
size = int(sys.argv[3]) if len(sys.argv) > 3 else 768
seeds = int(sys.argv[4]) if len(sys.argv) > 4 else 2
lo = int(sys.argv[5]) if len(sys.argv) > 5 else 0
hi = int(sys.argv[6]) if len(sys.argv) > 6 else len(SUBJECTS)
rembg = int(sys.argv[7]) if len(sys.argv) > 7 else 1
os.makedirs(out, exist_ok=True)
subs = SUBJECTS[lo:hi]
cell, pad, label, cols = 200, 4, 16, 9
rows = (len(subs) + cols - 1) // cols
sheet = Image.new("RGB", (cols * (cell + pad) + pad, rows * (seeds * (cell + pad) + label) + pad), (40, 40, 40))
draw = ImageDraw.Draw(sheet)
for i, (name, body) in enumerate(subs):
# 2차: 대상이 장식에 묻혀 가중치를 주고, 물건 하나만 가운데 오게 적는다
prompt = "%s, (%s:1.35), no humans, single item, centered, still life" % (PREFIX, body)
cx = pad + (i % cols) * (cell + pad)
cy = pad + (i // cols) * (seeds * (cell + pad) + label)
draw.text((cx + 3, cy + 2), name, fill=(240, 220, 160))
for j in range(seeds):
t0 = time.time()
im = run(prompt, 9000 + (lo + i) * 23 + j * 401, size, rembg)
print("%s %s #%d %.1f초" % (tag, name, j, time.time() - t0)); sys.stdout.flush()
im.save(os.path.join(out, "%s_%s_%d.png" % (tag, name, j)))
bg = checker(cell)
small = im.resize((cell, cell), Image.LANCZOS)
bg.paste(small, (0, 0), small)
sheet.paste(bg, (cx, cy + label + j * (cell + pad)))
path = os.path.join(out, "%s_sheet.png" % tag)
sheet.save(path)
print("sheet", path)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,451 @@
# -*- coding: utf-8 -*-
# 2026-10-02 PD 지시 — "내가 원하는 아트풍은 첨부한 아트풍(Game Icon Research Institute LoRA-ANFG_V3 예시)인데, 우리 프로젝트의 원본 에셋이 너무 실사풍이라…"
# → 원본 에셋을 따라 칠하는 방식(comfy_moba_repaint*.py)은 실사풍을 그대로 끌고 오므로 방향이 틀렸다.
# 여기서는 원본 에셋을 쓰지 않고, 양식화된 게임 아이콘 기반 모델(mobaGameEquipment_v10) 위에 ANFG v3 LoRA 를 얹어 글자만으로 뽑는다.
# (10-01 시도는 기반이 실사풍 difconsistency 였다 — 얇고 사실적으로 나와 PD 가 반려. 이 조합은 처음이다.)
# python comfy_moba_anfg.py <출력폴더> <round 이름> [LoRA 강도들 "0.7,1.0"] [시드 수=2] [크기=512] [다듬기 0|1]
import io, json, os, sys, time, uuid, urllib.parse
from PIL import Image, ImageDraw
import comfy_moba_repaint as base # 통신 · 바둑판 바탕은 앞선 스크립트의 것을 쓴다
CKPT = "mobaGameEquipment_v10.safetensors"
LORA = "giI_anfg_v3_000018.safetensors"
TRIGGER = "game icon institute, game icon, ahfg" # PD 가 알려 준 트리거
NEG = ("(worst quality, low quality:1.3), lowres, text, watermark, signature, blurry, photo, realistic, photorealistic, 3d render, thin, "
"1girl, 1boy, person, human, face, body, multiple objects, duplicate, frame, border, circle background")
UPSCALER = "4x-UltraSharp.pth"
# 2026-10-02 PD 지적 — a2 선별본(final_sheet)이 "너무 캐주얼한 느낌". 예시는 어두운 금속 · 정교한 장식 · 깊은 명암의 MMORPG 풍인데
# a2 는 묘사가 단순하고(금 반지 + 붉은 보석) 다듬기(0.35)가 면을 매끈하게 밀어 만화풍이 됐다.
# 분위기 사전 설정(환경변수 ANFG_TONE): 프롬프트 뒤에 붙는 낱말 · 부정어에 더하는 낱말. 다듬기 세기는 ANFG_REFINE(기본 0.35).
TONES = {
"": ("", ""),
"epic": (", epic fantasy mmorpg equipment, ornate, intricate engraved details, dark metal, painterly shading, dramatic lighting, deep shadows, glowing accents",
", cartoon, casual, cute, chibi, toy, flat color, simple design, cel shading, thick outline"),
# a4(epic) 검토: 명암 · 질감은 목표에 가까워졌으나 형태가 녹고(반지 → 가면, 벨트 → 칼날) 빛 번짐 · 잘림이 늘었다. LoRA 1.0 은 형태가 더 녹는다.
# refined: PD 가 고른 것의 공통점(실제 장비 비례 · 얇은 외곽선 · 면 안의 명암 단계 · 금 + 한 색)을 낱말로 옮기고, 발광 · 원색 · 굵은 외곽선을 부정어로 누른다.
"refined": (", realistic proportions, clean sharp edges, crisp silhouette, polished metal, subtle painterly shading, thin outline, soft studio light",
", cartoon, casual, cute, chibi, toy, flat color, thick black outline, neon colors, oversized gem, glow, bloom, smoke, flame, motion blur, cropped, cut off"),
# a9 검토: 형태는 길잡이로 풀렸고 남은 걸림돌은 "단순함"(도형에 색만 입힌 수준 · 명암 단계가 적어 플라스틱 느낌 · 정면 평면 구도).
# rich: 장식 밀도와 명암을 올린다. 길잡이 ControlNet 은 일찍 풀어(끝 0.35) 모델이 장식을 덧붙일 여지를 준다.
# a10 검토: 단검 · 방패는 장식 · 명암이 PD 합격본 수준에 근접. 다만 ControlNet 끝 0.35 는 24장 중 약 10장이 길잡이를 벗어났고(활 → 덩어리, 반지 → 메달),
# "embedded gemstones" 는 망치 머리 · 반지 안을 통보석으로 만들었고, "three-quarter view" 는 효과가 약했다 → 두 낱말을 빼고 끝을 0.5 로(가는 종류는 0.6). ANFG 는 0.7 고정(0.9 는 형태가 녹는다).
"rich": (", ornate fantasy design, engraved filigree, layered metal plates, crisp silhouette, clean sharp edges, "
"detailed painterly shading, painterly brush strokes, strong highlights and deep shadows, dark ambient occlusion",
", cartoon, casual, cute, chibi, toy, plastic, glossy plastic, plain, simple design, flat color, flat front view, thick black outline, neon colors, glow, bloom, smoke, flame, motion blur, cropped, cut off, "
"round medallion, coin, disc, close-up"),
# a14 검토: 배색 · 외곽선 · 밝기는 PD 합격본과 한 세트. 남은 차이 = 손그림 붓결과 깊은 그림자(깨끗한 그라데이션 + 흰 하이라이트 띠가 지배적).
# paint: 붓결 · 무광 · 어두운 바탕색 쪽 낱말로 바꾸고, 매끈함 · 광택을 부정어로 누른다(다듬기 방식 비교와 함께 a16 에서 시험).
"paint": (", ornate fantasy design, engraved filigree, layered metal plates, crisp silhouette, hand painted texture, visible painterly brush strokes, "
"matte dark metal, deep shadows, dark ambient occlusion, rich muted colors, strong rim light, specular highlights, high contrast",
", cartoon, casual, cute, chibi, toy, plastic, glossy plastic, plain, simple design, flat color, flat front view, thick black outline, neon colors, glow, bloom, smoke, flame, motion blur, cropped, cut off, "
"round medallion, coin, disc, close-up, smooth gradient, airbrushed, shiny, white highlight band, 3d render, purple, magenta, pink"),
}
# 구도 낱말(맨 앞) — 분위기 설정이 있을 때만 붙인다(a1 · a2 재현을 깨지 않게)
COMPOSE = "single item, centered, full object in frame, wide margin, "
TONE = os.environ.get("ANFG_TONE", "")
REFINE = float(os.environ.get("ANFG_REFINE", "0.35"))
# 자체 학습 LoRA(comfy_anfg_train.py 가 만든 FR_anfg_<버전>)를 ANFG LoRA 위에 함께 건다 — 환경변수 ANFG_LORA2 = "파일이름:강도". 걸면 트리거 fricon 을 앞에 붙인다.
LORA2 = os.environ.get("ANFG_LORA2", "")
# 1024 로 키우는 방식: model = 4x-UltraSharp(선명하지만 면을 매끈하게 민다) · lanczos = 단순 확대 뒤 다듬기에서 모델이 다시 칠하게 둔다(붓결을 살리려는 시험)
UPSCALE = os.environ.get("ANFG_UPSCALE", "model")
# PD 가 보여 준 예시와 같은 종류(반지 · 투구 · 장갑 · 갑옷 · 목걸이) + FR 부위(검 · 부츠 · 벨트)
SUBJECTS = [
("ring", "a blue dragon head ring, silver scales, glowing cyan"),
("helmet", "a red and gold horned helmet"),
("gloves", "a single armored gauntlet, dark steel with gold trim and purple gem"),
("armor", "a red and gold chest armor with shoulder plates"),
("necklace", "a red gem pendant necklace with gold wings"),
("sword", "a broad sword, blue glowing blade, gold hilt"),
("boots", "a pair of armored boots, blue and gold"),
("belt", "a leather belt with large gold buckle and red gem"),
]
# FR 장비 전 부위(무기 6 · 방어구 4 · 장신구 2 · 보조 장비 8) — a1 에서 형태가 약했던 벨트 · 검 · 장갑은 낱말을 구체적으로 바꿨다
SUBJECTS_FR = [
("sword", "a single longsword, steel blade, gold crossguard, blue gem, full weapon in frame"),
("blunt", "a single war hammer, heavy steel head with gold trim, full weapon in frame"),
("bow", "a single recurve bow, wooden with gold tips and green gem, full weapon in frame"),
("dagger", "a single dagger, curved steel blade, red gem pommel, full weapon in frame"),
("staff", "a single magic staff, gold top holding a purple crystal, full weapon in frame"),
("magic_dagger", "a single magic dagger, glowing green crystal blade, gold hilt, full weapon in frame"),
("armor", "a red and gold chest armor with shoulder plates"),
("belt", "a waist belt, wide brown leather strap loop, large round gold buckle with red gem"),
("gloves", "a pair of armored gloves, steel fingers, gold trim, purple gem"),
("boots", "a pair of armored boots, blue and gold"),
("ring", "a gold ring with a large red gemstone"),
("necklace", "a blue gem pendant necklace with gold chain"),
("shield", "a kite shield, blue steel with gold rim and lion emblem"),
("quiver", "a leather quiver full of arrows, gold trim"),
("spellbook", "a closed thick spellbook, purple cover, gold corners, glowing gem"),
("throwing", "a four pointed steel shuriken with gold center"),
("orb", "a glowing cyan crystal orb held in a gold claw stand"),
("offhand_sword", "a single short sword, wide blade, gold hilt, full weapon in frame"),
("totem", "a carved wooden totem with glowing green runes and feathers"),
("horn", "a gold trimmed war horn"),
]
# a2 검토(직접 봄): 풍은 일정하나 긴 무기(둔기 · 활 · 지팡이 · 화살통)가 전부 검 모양으로 쏠리고, 장식 테두리 · 화면 밖 잘림이 섞였다.
# a3: FR 원본 아이콘에서 "윤곽선만" 빌려 형태를 잡는다(색 · 질감은 가져오지 않으므로 실사풍이 따라오지 않는다). (이름, 묘사, 윤곽을 빌릴 원본 번호)
SUBJECTS_SHAPE = [
("blunt", "a war hammer, heavy steel head with gold trim and blue gem, brown handle", 1015),
("mace", "a flanged mace, gold and steel head with red gem, dark handle", 1057),
("bow", "a recurve bow, green and gold limbs, thin bowstring", 1029),
("dagger", "a dagger, steel blade, gold guard, red gem", 1043),
("sword", "a short sword, blue steel blade, gold crossguard", 1435),
("belt1", "a leather belt, brown strap, gold buckle with red gem", 1337),
("belt2", "a leather belt, dark strap, gold skull buckle, cyan gems", 1351),
("belt3", "a leather belt with gold plates and hanging chains, cyan gems", 1309),
("gloves1", "a pair of armored gloves, purple leather, gold trim, glowing runes", 1169),
("gloves2", "a pair of steel gauntlets, gold trim, red gems", 1211),
("boots1", "a pair of armored boots, blue and gold", 1393),
("boots2", "a pair of high plate boots, red and gold", 1421),
("armor1", "a chest armor, red and gold plates, shoulder pauldrons", 1253),
("armor2", "a chest armor, blue steel and gold, fur collar, cyan gem", 1239),
("necklace1", "a round amulet, gold rim, glowing cyan gem", 1099),
("necklace2", "a shield shaped pendant, silver and gold, blue gem", 1113),
]
# a5 검토(에이전트가 직접 봄): 글자만으로는 검 · 단검 · 갑옷 · 장화만 안정. 둔기 · 활 · 지팡이 · 화살통 · 뿔피리 등은 칼날 모양으로 쏠리고, 벨트 · 투척 · 방패는 원형 바탕이 붙는다.
# SUBJECTS_ALL: (이름, 묘사, 윤곽 원본 번호 또는 None, 종류별 부정어). 묘사 · 부정어는 a5 검토의 수정안을 반영했다.
SUBJECTS_ALL = [
("sword", "a single longsword, steel blade, gold crossguard, blue gem, full weapon in frame", None, "two blades"),
("blunt", "a one-handed flanged mace, heavy spiked steel head with gold trim on a short handle", 1057, "sword, blade"),
("hammer", "a war hammer, heavy block steel head with gold trim and blue gem, brown wooden handle", 1015, "sword, blade"),
("bow", "a recurve bow, curved wooden limbs with gold tips, thin taut bowstring, side view", 1029, "sword, blade, shield, arrow, harp, panel, banner, sail"),
("dagger", "a single dagger, curved steel blade, red gem pommel, full weapon in frame", None, "two blades"),
("staff", "a long wooden wizard staff, full length visible, purple crystal on top, diagonal", None, "sword, blade, scepter"),
("magic_dagger", "a single magic dagger, green crystal blade, gold hilt, full weapon in frame", None, "two blades, floating orbs"),
("armor", "a red and gold chest armor with shoulder plates", None, ""),
("belt", "a leather waist belt laid flat as a horizontal strap, large gold buckle in the center, red leather", 1337, "ring, circle, pants, torso, round frame"),
("gloves", "a single armored gauntlet, five fingers, steel plate glove with gold trim, wrist cuff", None, "boot, bare hand"),
("boots", "a pair of armored boots, blue and gold", None, ""),
("ring", "a gold ring with a large red gemstone, closed circular finger ring band", None, "ribbon, broken ring"),
("necklace", "a pendant necklace, short closed chain loop fully visible, blue gem pendant with gold setting", None, "bottle, wings, cropped chain"),
("shield", "a kite shield, plain blue field with gold rim and central boss, three-quarter view, small in frame", None, "lion, animal, face, crest, round frame"),
("quiver", "a leather quiver full of arrows, feathered arrow shafts sticking out of the top, shoulder strap", None, "sword, blade"),
("spellbook", "a closed thick spellbook, purple cover, gold corner clasps, gem on the front cover, three-quarter view", None, "open book, scroll"),
("throwing", "a single steel throwing knife, kunai, small leaf shaped blade with ring pommel", None, "shuriken, star, disc, wheel, round frame"),
("orb", "a crystal ball, deep blue glass sphere on a small gold stand", None, "cyan, teal, horns"),
("offhand_sword", "a short parrying dagger, main-gauche, wide curved gold handguard, whole weapon in frame", None, "two blades"),
("totem", "a small carved wooden idol statue, tribal face, gold bands, red gem eyes", None, "green glow, neon, pillar, column, round frame"),
("horn", "a curved ivory war horn, blowing horn, gold rim bands, leather strap", None, "axe, blade, trumpet, all gold"),
]
# 초안 방식(draft): 물건을 정확히 아는 실사풍 모델로 먼저 "형태 초안"을 그리고, 그 초안의 윤곽선만 ControlNet 으로 넘겨 ANFG 풍으로 새로 그린다.
# 색 · 질감은 넘어가지 않으므로 실사풍이 따라오지 않고, 글자만으로는 못 잡던 형태(활 · 화살통 · 뿔피리 등)를 잡는다 — 어떤 장비든 글자로 뽑기 위한 방법.
DRAFT_CKPT = "difconsistencyCheckpoint_v10.safetensors"
DRAFT_NEG = "person, human, hand, character, multiple objects, text, watermark, frame, border, cropped, cut off, blurry, lowres"
def add_draft(wf, desc, seed, cn_strength, cn_end):
"""형태 초안(d*) → 굵은 선화 → ControlNet 을 글자 조건(4 · 5)에 걸어 샘플러(7)에 물린다."""
wf["d1"] = {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": DRAFT_CKPT}}
wf["d4"] = {"class_type": "CLIPTextEncode", "inputs": {"clip": ["d1", 1], "text": "%s, single object, centered, whole object visible, wide margin, game item concept art, plain dark background" % desc}}
wf["d5"] = {"class_type": "CLIPTextEncode", "inputs": {"clip": ["d1", 1], "text": DRAFT_NEG}}
wf["d6"] = {"class_type": "EmptyLatentImage", "inputs": {"width": 512, "height": 512, "batch_size": 1}}
wf["d7"] = {"class_type": "KSampler", "inputs": {"model": ["d1", 0], "positive": ["d4", 0], "negative": ["d5", 0], "latent_image": ["d6", 0], "seed": seed + 7,
"steps": 24, "cfg": 7.0, "sampler_name": "dpmpp_2m", "scheduler": "karras", "denoise": 1.0}}
wf["d8"] = {"class_type": "VAEDecode", "inputs": {"samples": ["d7", 0], "vae": ["d1", 2]}}
wf["d9"] = {"class_type": "SaveImage", "inputs": {"images": ["d8", 0], "filename_prefix": "moba_anfg_draft"}}
wf["30"] = {"class_type": "ControlNetLoader", "inputs": {"control_net_name": CN_LINEART}}
wf["31"] = {"class_type": "LineArtPreprocessor", "inputs": {"image": ["d8", 0], "coarse": "enable", "resolution": 512}}
wf["32"] = {"class_type": "ControlNetApplyAdvanced", "inputs": {"positive": ["4", 0], "negative": ["5", 0], "control_net": ["30", 0], "image": ["31", 0],
"strength": cn_strength, "start_percent": 0.0, "end_percent": cn_end, "vae": ["1", 2]}}
wf["7"]["inputs"]["positive"] = ["32", 0]; wf["7"]["inputs"]["negative"] = ["32", 1]
return wf
# 길잡이 모음 방식(lib): icon_shapes.py 가 만든 moba_anfg/shapes/<종류>__<출처>.png 의 윤곽선으로 형태를 잡는다(a6 검토에서 가장 정확했던 방식).
# 종류별 묘사의 {pal} 자리에 색 조합을 돌려 넣어, 같은 형태라도 다른 디자인이 나오게 한다(학습 데이터가 한 색으로 쏠리지 않게).
TYPE_DESC = {
"sword": "a longsword, ornate engraved steel blade with a fuller, dark steel shading, ornate crossguard, {pal}",
"dagger": "a dagger, engraved steel blade with a fuller, dark steel shading, ornate hilt and crossguard, {pal}",
"blunt": "a flanged mace, heavy spiked metal head on a short handle, {pal}",
"hammer": "a war hammer, heavy block metal head with ornate banded ends and an engraved emblem, wrapped handle, {pal}",
"axe": "a single-bladed battle axe, solid broad axe head attached to the shaft, engraved steel blade, dark steel shading, wooden shaft with metal fittings, {pal}",
"bow": "an ornate recurve bow, carved limbs with gold filigree fittings, gem at the wrapped grip, thin bowstring, {pal}",
"staff": "a wizard staff, long shaft, glowing crystal head ornament, {pal}",
"armor": "a chest armor, layered plates, shoulder pauldrons, {pal}",
"helmet": "a knight helmet, visor, crest, {pal}",
"belt": "a waist belt, leather strap, large ornate buckle, {pal}",
"gloves": "a pair of armored gauntlets, plated fingers, wrist cuffs, {pal}",
"boots": "a pair of armored boots, plated shins, {pal}",
"ring": "a finger ring, large faceted gemstone, engraved band, {pal}",
"necklace": "a pendant amulet, faceted gem in an ornate setting, {pal}",
"shield": "a shield, metal rim, central emblem boss, {pal}",
"quiver": "a leather quiver full of arrows, feathered arrows sticking out, shoulder strap, {pal}",
"spellbook": "a closed spellbook, thick tome, metal corner clasps, gem on the cover, {pal}",
"throwing": "a throwing blade, kunai, sharp steel, {pal}",
"orb": "a crystal orb, glass sphere on an ornate stand, {pal}",
"totem": "a carved wooden totem, stacked tribal faces, small wings, {pal}",
"horn": "a curved war horn, ivory body, metal rim bands, {pal}",
}
# PD 가 고른 것 = 금 + 적 또는 금 + 청 + 은빛 금속. 그 둘을 앞에 두고 나머지로 폭을 넓힌다.
# a7 검토: 여섯 색을 돌리니 청록 · 보라 · 초록 단색 칠이 40% 쯤 나와 불합격이 늘었다 → 금을 늘 넣고 한 색만 더한다. 은 단색은 세트에서 차갑게 떠서 뺐다.
PALETTES = ["gold and crimson red with silver steel", "gold and royal blue with silver steel", "gold trim on dark steel with red gems", "gold trim on silver steel with blue gems"]
# a21 뒤 최상급 점검(검토): 세트로는 묶이지만 목표 풍과 다른 점 = 원색 파랑이 전면(30장 중 약 20장) · 목표는 어둡고 채도 낮은 바탕에 포인트만 빛난다.
# ANFG_PAL=dark 이면 어두운 바탕색 + 작은 강조색 조합을 쓴다(PD 에게 방향 비교로 보여 줄 시험용 — PD 가 고른 합격본은 금 + 적/청의 밝은 쪽이다).
if os.environ.get("ANFG_PAL", "") == "dark":
PALETTES = ["dark steel with antique gold trim and a small glowing blue gem accent", "dark iron and antique gold with deep crimson accents",
"blackened steel with bronze trim and a small glowing red gem accent", "dark silver and antique gold with deep blue enamel accents"]
# 2026-10-03 PD 지적 — "붉은색 · 푸른색 같은 원색 계열만 쓰고 있어서 색감이 풍부하지 않고 저급해 보인다" + 레퍼런스(핀터레스트 → ArtStation "RPG Weapon Icons 02", Olekzandr Zahorulko).
# 레퍼런스에서 본 것: 색이 "칠"이 아니라 "재질"에서 나온다(강철 회색 · 나무 갈색 · 가죽 · 뼈 · 청동 · 녹슨 쇠). 원색은 물건 전체가 아니라 작은 강조(감은 끈 · 보석 · 마법 빛)에만,
# 그 강조색이 물건마다 다르다(청록 · 보라 · 초록 · 주황 · 분홍 · 하늘). 어두운 바탕 위에서 명암 폭이 넓다.
# 우리 문제: 색 조합이 "금 + 적 / 금 + 청" 넷뿐이고 물건 전체를 그 색 에나멜로 칠한다 · 자체 LoRA v4 가 그 배색으로 학습돼 더 끌어당긴다.
# ANFG_PAL=material: 재질 조합(몸체) × 강조색(작은 부분)을 따로 돌린다 → 조합 수가 많아 물건마다 색이 달라진다.
MATERIALS = ["weathered steel and dark walnut wood", "dark iron and worn brown leather", "aged bronze and bone ivory", "blackened steel and antique brass",
"pale silver and deep burgundy leather", "tarnished copper and dark oak wood", "gunmetal grey steel and tan leather wraps", "antique gold and dark slate grey steel"]
ACCENTS = ["a small emerald green gem accent", "a small amethyst purple gem accent", "a small teal cyan glowing accent", "a small amber orange gem accent",
"a small ruby red gem accent", "a small sapphire blue gem accent", "a small jade green inlay accent", "a small rose pink crystal accent",
"a small turquoise inlay accent", "a small pale moonstone white gem accent"]
if os.environ.get("ANFG_PAL", "") == "material":
# a25(PM 이 비교 시트를 직접 봄): 반지 · 벨트 · 방패 · 목걸이 · 망치 · 검은 재질색 + 작은 강조로 잘 나왔으나, 갑옷 · 장화 · 투구는 강조색이 몸체 전체를 덮었다(보라 갑옷 · 초록 투구).
# → 재질에 가중치를 주고 "몸체는 재질색, 강조는 작은 보석 하나"임을 낱말로 못박는다.
PALETTES = ["(body made of %s:1.25), natural material colors, %s only" % (m, ACCENTS[(k * 3 + j) % len(ACCENTS)]) for j in range(5) for k, m in enumerate(MATERIALS)]
# a26 검토(에이전트 + PM 이 최상급 24장 직접 확인): 원색 문제는 약 70% 에서 풀리고 세트가 확실히 풍부해졌다. 남은 문제 —
# ① 재질 낱말이 엉뚱한 부위에 붙는다(나무 칼날 · 나무 투구 · 뼈 벨트) ② 장식 밀도가 줄어 민무늬 면이 늘었다 ③ 갈색 쪽으로 쏠려 차가운 재질이 적다 ④ 일부는 여전히 한 색이 몸체를 덮는다.
# ANFG_PAL=parts: 재질을 "부위"에 묶는다. TYPE_PARTS 의 묘사에 {metal}(주 금속) · {metal2}(장식 금속) · {soft}(가죽/나무/천) · {accent}(작은 강조색) 자리를 두고 따로 돌린다.
METALS = ["weathered steel", "blued steel", "dark iron", "aged bronze", "verdigris bronze", "pale silver", "blackened steel", "antique gold", "tarnished copper", "gunmetal grey steel"]
METALS2 = ["antique brass trim", "silver trim", "antique gold trim", "dark iron rivets and trim", "bronze trim"]
SOFTS = ["dark walnut wood", "worn brown leather", "deep burgundy leather", "tan leather wraps", "dark oak wood", "teal leather wrap", "black leather", "bone ivory"]
ACCENTS2 = ["a small emerald green gem", "a small amethyst purple gem", "a small turquoise inlay", "a small amber orange gem", "a small ruby red gem",
"a small sapphire blue gem", "a small jade green inlay", "a small moonstone white gem", "a small topaz yellow gem", "a small garnet dark red gem"]
ORNATE = "engraved filigree, etched runes, rivets, worn edges, fine scratches"
TYPE_PARTS = {
"sword": "a longsword, {metal} blade with a fuller, {metal2} crossguard, {soft} wrapped grip, {accent} in the pommel",
"dagger": "a dagger, {metal} blade with a fuller, {metal2} crossguard, {soft} wrapped grip, {accent} in the pommel",
"axe": "a single-bladed battle axe, solid broad {metal} axe head attached to the shaft, {soft} shaft with {metal2}, {accent} on the axe head",
"blunt": "a flanged mace, heavy spiked {metal} head, {soft} wrapped handle with {metal2}, {accent} on the head",
"hammer": "a war hammer, heavy block {metal} head with banded ends, {soft} wrapped handle with {metal2}, {accent} on the head",
"bow": "an ornate recurve bow, carved {soft} limbs with {metal} fittings, wrapped grip, thin bowstring, {accent} at the grip",
"staff": "a wizard staff, long {soft} shaft with {metal} fittings, crystal head ornament, {accent} glowing softly",
"armor": "a chest armor, layered {metal} plates with {metal2}, {soft} straps, shoulder pauldrons, {accent} on the chest",
"helmet": "a knight helmet, {metal} plates with {metal2}, visor, crest, {accent} on the brow",
"belt": "a waist belt, {soft} strap, large ornate {metal} buckle with {metal2}, {accent} in the buckle",
"gloves": "a pair of armored gauntlets, {metal} plated fingers with {metal2}, {soft} cuffs, {accent} on the back of the hand",
"boots": "a pair of armored boots, {metal} plated shins with {metal2}, {soft} boot, {accent} on the shin",
"ring": "a finger ring, engraved {metal} band with {metal2}, {accent} in an ornate setting",
"necklace": "a pendant amulet, {metal} ornate setting with {metal2}, {accent} in the center",
"shield": "a shield, {metal} rim with {metal2}, {soft} covered face, central emblem boss, {accent} on the boss",
"quiver": "a quiver full of arrows, {soft} body with {metal} fittings, feathered arrows sticking out, shoulder strap, {accent} on the fitting",
"spellbook": "a closed spellbook, thick tome, {soft} cover, {metal} corner clasps, {accent} on the cover",
"throwing": "a throwing blade, kunai, sharp {metal} blade, {soft} wrapped grip, {accent} on the ring pommel",
"orb": "a crystal orb, glass sphere on an ornate {metal} stand with {metal2}, {accent} glow inside the sphere",
"totem": "a carved totem, {soft} body with stacked tribal faces, {metal} bands, small wings, {accent} eyes",
"horn": "a curved war horn, bone ivory body, {metal} rim bands with {metal2}, {soft} strap, {accent} on the band",
}
PARTS = os.environ.get("ANFG_PAL", "") == "parts"
def parts_desc(kind, n):
"""종류 kind 의 n 번째 색 조합 묘사 — 금속 · 장식 금속 · 부드러운 재질 · 강조색을 서로 다른 걸음으로 돌려 겹치지 않게 한다"""
return TYPE_PARTS[kind].format(metal=METALS[n % len(METALS)], metal2=METALS2[(n // 2) % len(METALS2)],
soft=SOFTS[(n * 3 + 1) % len(SOFTS)], accent=ACCENTS2[(n * 7 + 2) % len(ACCENTS2)]) + ", " + ORNATE
# 색을 누르는 부정어(재질 조합일 때만 붙인다)
MATERIAL_NEG = ", saturated primary colors, blue enamel, red enamel, fully blue, fully red, all gold, monochrome gold, two-tone color scheme, candy colors, fully purple, fully green, fully teal, colored metal plates, painted armor, colored enamel body, red armor, magenta, wooden blade, circular background, glow outline, halo"
SHAPES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "moba_anfg", "shapes")
# icon_make.py 가 적는 종류별 묘사(새 종류 · 묘사 고침)를 표에 합친다
_DESC_JSON = os.path.join(os.path.dirname(os.path.abspath(__file__)), "moba_anfg", "type_desc.json")
if os.path.exists(_DESC_JSON):
TYPE_DESC.update(json.load(io.open(_DESC_JSON, encoding="utf-8")))
def lib_subjects():
"""길잡이 모음의 그림마다 대상 한 줄: (이름 = 종류-출처, 묘사, 길잡이 경로, 부정어, 종류)"""
import glob
neg = dict((x[0], x[3]) for x in SUBJECTS_ALL)
subs = []
act = os.path.join(SHAPES_DIR, "active.txt") # icon_shapes.py 가 적는 "지금 쓰는 길잡이" 목록
names = io.open(act, encoding="utf-8").read().split() if os.path.exists(act) else [os.path.basename(f) for f in sorted(glob.glob(os.path.join(SHAPES_DIR, "*__*.png")))]
for k, path in enumerate(os.path.join(SHAPES_DIR, n) for n in names):
kind, label = os.path.basename(path)[:-4].split("__", 1)
if kind in TYPE_DESC:
subs.append((kind + "-" + label.replace("_", ""), TYPE_DESC[kind], path, neg.get(kind, ""), kind))
return subs
SRC = base.SRC
CN_LINEART = "control_v11p_sd15_lineart_fp16.safetensors"
FRAME_NEG = ", ornate frame, picture frame, card, corner ornaments, vignette, ring shaped background, out of frame"
def shape_image(gid, fit=0.72):
"""원본 아이콘을 1024 캔버스 가운데에 여백을 넉넉히 두고 놓는다(윤곽 추출용 · 여백이 곧 완성본의 여백)"""
im = Image.open(os.path.join(SRC, "%d.png" % gid)).convert("RGBA")
box = im.getchannel("A").getbbox()
if box:
im = im.crop(box)
scale = 1024 * fit / float(max(im.size))
im = im.resize((max(1, int(im.width * scale)), max(1, int(im.height * scale))), Image.LANCZOS)
rgb = Image.new("RGB", (1024, 1024), base.BG)
rgb.paste(im, ((1024 - im.width) // 2, (1024 - im.height) // 2), im)
return rgb
def add_shape(wf, image_name, cn_strength, cn_end):
"""글자 조건(4 · 5)에 윤곽 ControlNet 을 걸어 샘플러(7 · 44)에 물린다. 굵은 선(coarse)만 써서 세부는 모델이 새로 그리게 둔다."""
wf["22"] = {"class_type": "LoadImage", "inputs": {"image": image_name}}
wf["30"] = {"class_type": "ControlNetLoader", "inputs": {"control_net_name": CN_LINEART}}
wf["31"] = {"class_type": "LineArtPreprocessor", "inputs": {"image": ["22", 0], "coarse": "enable", "resolution": 768}}
wf["32"] = {"class_type": "ControlNetApplyAdvanced", "inputs": {"positive": ["4", 0], "negative": ["5", 0], "control_net": ["30", 0], "image": ["31", 0],
"strength": cn_strength, "start_percent": 0.0, "end_percent": cn_end, "vae": ["1", 2]}}
wf["7"]["inputs"]["positive"] = ["32", 0]; wf["7"]["inputs"]["negative"] = ["32", 1]
return wf
def workflow(prompt, seed, strength, size, refine, extra_neg=""):
wf = {
"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": CKPT}},
"2": {"class_type": "LoraLoader", "inputs": {"model": ["1", 0], "clip": ["1", 1], "lora_name": LORA, "strength_model": strength, "strength_clip": strength}},
"3": {"class_type": "CLIPSetLastLayer", "inputs": {"clip": ["2", 1], "stop_at_clip_layer": -2}},
"4": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["3", 0], "text": prompt}},
"5": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["3", 0], "text": NEG + FRAME_NEG + (TONES[TONE][1].replace(", purple, magenta, pink", "") if os.environ.get("ANFG_PAL", "") in ("material", "parts") else TONES[TONE][1]) + (MATERIAL_NEG if os.environ.get("ANFG_PAL", "") in ("material", "parts") else "") + ((", " + extra_neg) if extra_neg else "")}},
"6": {"class_type": "EmptyLatentImage", "inputs": {"width": size, "height": size, "batch_size": 1}},
"7": {"class_type": "KSampler", "inputs": {"model": ["2", 0], "positive": ["4", 0], "negative": ["5", 0], "latent_image": ["6", 0], "seed": seed,
"steps": 30, "cfg": 7.0, "sampler_name": "dpmpp_2m", "scheduler": "karras", "denoise": 1.0}},
"8": {"class_type": "VAEDecode", "inputs": {"samples": ["7", 0], "vae": ["1", 2]}},
}
model = ["2", 0]
if LORA2:
name2, strength2 = LORA2.rsplit(":", 1)
wf["2b"] = {"class_type": "LoraLoaderModelOnly", "inputs": {"model": ["2", 0], "lora_name": name2, "strength_model": float(strength2)}}
model = ["2b", 0]
wf["7"]["inputs"]["model"] = model
last = "8"
if refine:
# 4배 확대 → 1024 → 약하게 다듬기(세부 선명도)
wf["40"] = {"class_type": "UpscaleModelLoader", "inputs": {"model_name": UPSCALER}}
wf["41"] = {"class_type": "ImageUpscaleWithModel", "inputs": {"upscale_model": ["40", 0], "image": ["8", 0]}}
wf["42"] = {"class_type": "ImageScale", "inputs": {"image": ["41", 0] if UPSCALE == "model" else ["8", 0], "upscale_method": "lanczos", "width": 1024, "height": 1024, "crop": "disabled"}}
if UPSCALE != "model":
del wf["40"]; del wf["41"]
wf["43"] = {"class_type": "VAEEncode", "inputs": {"pixels": ["42", 0], "vae": ["1", 2]}}
wf["44"] = {"class_type": "KSampler", "inputs": {"model": model, "positive": ["4", 0], "negative": ["5", 0], "latent_image": ["43", 0], "seed": seed + 1,
"steps": 24, "cfg": 7.0, "sampler_name": "dpmpp_2m", "scheduler": "karras", "denoise": REFINE}}
wf["45"] = {"class_type": "VAEDecode", "inputs": {"samples": ["44", 0], "vae": ["1", 2]}}
last = "45"
wf["12"] = {"class_type": "InspyrenetRembg", "inputs": {"image": [last, 0], "torchscript_jit": "default"}}
wf["9"] = {"class_type": "SaveImage", "inputs": {"images": ["12", 0], "filename_prefix": "moba_anfg"}}
return wf
def run(wf, want="9"):
pid = base.post("/prompt", {"prompt": wf, "client_id": str(uuid.uuid4())})["prompt_id"]
t0 = time.time()
while time.time() - t0 < 900:
hist = json.loads(base.get("/history/" + pid))
if pid in hist:
st = hist[pid].get("status", {})
if st.get("status_str") == "error":
raise RuntimeError(json.dumps(st)[:1500])
for nid, node in hist[pid].get("outputs", {}).items():
if nid != want:
continue
for im in node.get("images", []):
q = urllib.parse.urlencode({"filename": im["filename"], "subfolder": im.get("subfolder", ""), "type": im.get("type", "output")})
return Image.open(io.BytesIO(base.get("/view?" + q))).convert("RGBA")
time.sleep(1.0)
raise TimeoutError("comfy timeout")
# a12 검토: 가는 무기(검 · 단검 · 도끼 · 활 · 지팡이 …)는 512 에서 날이 흰 납작면으로 뭉개진다 → 768 로 그리고(모델 권장 크기), 길잡이를 끝까지 더 오래 잡는다(끝 0.7).
THIN = ("sword", "dagger", "axe", "bow", "staff", "throwing", "blunt", "hammer", "magic_dagger", "offhand_sword")
THIN_SIZE = int(os.environ.get("ANFG_THIN_SIZE", "768"))
SEED_BASE = int(os.environ.get("ANFG_SEED", "0")) # 회차마다 다른 시드를 쓰려면 바꾼다
def hash_name(name):
"""종류 이름으로 시드를 흩는다(표의 순서가 바뀌어도 같은 종류는 같은 시드)"""
return sum(ord(c) * (k + 3) for k, c in enumerate(name)) % 9973
def main():
out, tag = sys.argv[1], sys.argv[2]
strengths = [float(v) for v in (sys.argv[3] if len(sys.argv) > 3 else "0.7,1.0").split(",")]
seeds = int(sys.argv[4]) if len(sys.argv) > 4 else 2
size = int(sys.argv[5]) if len(sys.argv) > 5 else 512
refine = int(sys.argv[6]) if len(sys.argv) > 6 else 0
global SUBJECTS
mode = sys.argv[7] if len(sys.argv) > 7 else ""
cn_strength = float(sys.argv[8]) if len(sys.argv) > 8 else 0.6
cn_end = float(sys.argv[9]) if len(sys.argv) > 9 else 0.6
if mode == "fr":
SUBJECTS = SUBJECTS_FR
elif mode == "shape":
SUBJECTS = SUBJECTS_SHAPE
elif mode in ("all", "draft", "allshape"):
SUBJECTS = SUBJECTS_ALL
elif mode == "lib":
SUBJECTS = lib_subjects()
only = os.environ.get("ANFG_ONLY", "") # 쉼표로 적은 종류만 돌린다
if only:
SUBJECTS = [x for x in SUBJECTS if x[0].split("-")[0] in only.split(",")]
skip = os.environ.get("ANFG_SKIP", "") # 행 이름에 이 글자가 들어 있으면 뺀다(예: genpd — PD 합격본 윤곽 길잡이는 복제를 만든다)
if skip:
SUBJECTS = [x for x in SUBJECTS if not any(k in x[0] for k in skip.split(","))]
rows = os.environ.get("ANFG_ROWS", "") # 쉼표로 적은 행 이름(종류-길잡이)만 돌린다
if rows:
SUBJECTS = [x for x in SUBJECTS if x[0] in rows.split(",")]
os.makedirs(out, exist_ok=True)
cols = len(strengths) * seeds
cell, pad, left = 220, 4, 80
sheet = Image.new("RGB", (left + cols * (cell + pad) + pad, len(SUBJECTS) * (cell + pad) + pad), (24, 24, 28))
draw = ImageDraw.Draw(sheet)
for i, sub in enumerate(SUBJECTS):
name, desc = sub[0], sub[1]
extra_neg = sub[3] if len(sub) > 3 else ""
use_shape = len(sub) > 2 and sub[2] and mode in ("shape", "allshape")
shape = base.upload(shape_image(sub[2]), "bt_anfg_shape_%d.png" % sub[2]) if use_shape else None
if mode == "lib":
guide = Image.open(sub[2]).convert("RGB")
if "-genpd" in name:
# a11 검토: PD 합격본의 윤곽을 그대로 넘기면 색만 바뀐 복제가 나온다 → 좌우를 뒤집어 넘긴다
guide = guide.transpose(Image.FLIP_LEFT_RIGHT)
shape = base.upload(guide, "bt_anfg_lib_%s.png" % name)
y = pad + i * (cell + pad)
draw.text((pad, y + 4), name, fill=(240, 220, 160))
col = 0
for s in strengths:
for j in range(seeds):
fn = "%s_%s_s%02d_%d.png" % (tag, name, int(s * 10), j)
# 길잡이 모음 방식은 그림마다 색 조합을 돌린다(종류 안 순번 + 시드 순번 + 회차 시드)
d1 = desc.format(pal=PALETTES[(i * 7 + j * 3 + SEED_BASE) % len(PALETTES)]) if "{pal}" in desc else desc
if PARTS and name.split("-")[0] in TYPE_PARTS:
d1 = parts_desc(name.split("-")[0], i * 7 + j * 3 + SEED_BASE)
prompt = "%s, %s%s, simple background, black background, no humans" % (("fricon, " if LORA2 else "") + TRIGGER, (COMPOSE if TONE else "") + d1, TONES[TONE][0])
# 이미 만든 그림은 다시 만들지 않는다(끊겨도 이어서 돌린다)
if os.path.exists(os.path.join(out, fn)):
im = Image.open(os.path.join(out, fn)).convert("RGBA")
else:
# 종류 표가 정해진 예전 회차(a1 · a2)는 순번 시드를 그대로 쓴다(재현). 새 표는 이름으로 흩는다.
seed = (50000 + i * 37 + j * 733) if mode in ("", "fr", "shape") else (50000 + SEED_BASE + hash_name(name) + j * 733)
kind = name.split("-")[0]
wf = workflow(prompt, seed, s, THIN_SIZE if kind in THIN else size, refine, extra_neg)
if shape:
# Layer Lab 길잡이는 캐주얼한 세부가 새므로 약하게 걸어 큰 윤곽만 잡는다
weak = mode == "lib" and "-ll" in name
# 가는 형태(반지 · 활 · 지팡이 · 투척)는 ControlNet 을 일찍 풀면 다른 물건이 된다 — 끝을 0.6 아래로 내리지 않는다
end = max(cn_end, 0.7) if kind in THIN else (max(cn_end, 0.6) if kind == "ring" else cn_end)
add_shape(wf, shape, 0.4 if weak else cn_strength, 0.4 if weak else end)
elif mode == "draft":
add_draft(wf, d1, seed, cn_strength, cn_end)
im = run(wf)
im.save(os.path.join(out, fn))
# 학습 캡션으로 쓸 묘사를 곁파일로 남긴다(icon_dataset.py 가 읽는다)
with io.open(os.path.join(out, fn[:-4] + ".txt"), "w", encoding="utf-8") as f:
f.write(d1)
# 예시와 견주기 쉽게 어두운 바탕에 올린다
bg = Image.new("RGB", (cell, cell), (12, 12, 14)); small = im.resize((cell, cell), Image.LANCZOS); bg.paste(small, (0, 0), small)
sheet.paste(bg, (left + col * (cell + pad), y))
draw.text((left + col * (cell + pad) + 3, y + 2), "LoRA %.1f #%d" % (s, j), fill=(200, 200, 200))
col += 1
print("%s %s" % (tag, name)); sys.stdout.flush()
sheet.save(os.path.join(out, "%s_sheet.png" % tag))
print("sheet", os.path.join(out, "%s_sheet.png" % tag))
if __name__ == "__main__":
main()

View File

@ -0,0 +1,164 @@
# -*- coding: utf-8 -*-
# 2026-10-01 PD 지시 — mobaGameEquipment_v10 결과 36장 중 "쓸 만한 것"은 3장(초록 마법 단검 · 은색 갑옷 · 갈색 부츠)뿐.
# 합격 기준(PD 가 고른 3장의 공통점): 물건 하나가 따로 떨어져 있다 · 원형 바탕/후광/딸린 조각이 없다 · 전체가 여백 안에 들어온다.
# 방법: 대상마다 여러 장을 뽑고(장당 약 7초), 투명도(알파)로 자동 채점해 기준에 맞는 것만 추린다 → 추린 것을 사람이 본다.
# python comfy_moba_batch.py <출력폴더> <round 이름> <대상당 장수> [시작] [끝]
# 자동 채점(배경 제거 뒤 알파 기준):
# · 불투명 비율 6~42% (원형 바탕이 붙으면 50% 를 넘는다) · 가장자리 닿음 1.5% 이하(잘림) · 가장 큰 덩어리가 불투명의 88% 이상(딸린 조각 없음)
import io, json, os, sys, time, uuid, urllib.request, urllib.parse
from collections import deque
from PIL import Image, ImageDraw
HOST = "http://127.0.0.1:8000"
CKPT = "mobaGameEquipment_v10.safetensors"
PREFIX = "(masterpiece:1.2), (best quality:1.2), moba game icon"
NEG = ("lowres, bad anatomy, bad hands, text, error, missing fingers, extra digit, fewer digits, cropped, worst quality, low quality, normal quality, jpeg artifacts, signature, watermark, username, blurry, "
"1girl, 1boy, person, human, character, face, hair, breasts, body, wearing, mannequin, bust, "
"circle, round frame, border, badge, emblem, aura, halo, light rays, glowing background, explosion, fire background, "
"multiple objects, two weapons, crossed weapons, duplicate, out of frame")
# 1 · 2차에서 형태가 약했던 대상은 낱말을 구체적으로 바꿨다(활 · 벨트 · 목걸이 · 화살통 · 반지 · 둔기 · 투척)
SUBJECTS = [
("sword", "longsword"),
("blunt", "one-handed war hammer"),
("bow", "longbow, wooden bow with bowstring"),
("dagger", "dagger"),
("staff", "magic staff with crystal"),
("magic_dagger", "magic dagger, glowing blade"),
("armor", "plate armor, breastplate"),
("belt", "leather belt with metal buckle"),
("gloves", "gauntlets"),
("boots", "leather boots"),
("ring", "gold ring with gemstone"),
("necklace", "amulet, pendant with gem on chain"),
("shield", "shield"),
("quiver", "arrow quiver, leather tube holding arrows"),
("spellbook", "closed spellbook, thick book"),
("throwing", "throwing knife"),
("orb", "crystal orb"),
("offhand_sword", "short sword"),
]
def post(path, data):
req = urllib.request.Request(HOST + path, data=json.dumps(data).encode("utf-8"), headers={"Content-Type": "application/json"})
return json.loads(urllib.request.urlopen(req, timeout=60).read())
def get(path):
return urllib.request.urlopen(HOST + path, timeout=300).read()
def workflow(prompt, seed):
return {
"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": CKPT}},
"3": {"class_type": "CLIPSetLastLayer", "inputs": {"clip": ["1", 1], "stop_at_clip_layer": -2}},
"4": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["3", 0], "text": prompt}},
"5": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["3", 0], "text": NEG}},
"6": {"class_type": "EmptyLatentImage", "inputs": {"width": 512, "height": 512, "batch_size": 1}},
"7": {"class_type": "KSampler", "inputs": {"model": ["1", 0], "positive": ["4", 0], "negative": ["5", 0], "latent_image": ["6", 0], "seed": seed, "steps": 30, "cfg": 7.0, "sampler_name": "dpmpp_2m", "scheduler": "karras", "denoise": 1.0}},
"8": {"class_type": "VAEDecode", "inputs": {"samples": ["7", 0], "vae": ["1", 2]}},
"12": {"class_type": "InspyrenetRembg", "inputs": {"image": ["8", 0], "torchscript_jit": "default"}},
"9": {"class_type": "SaveImage", "inputs": {"images": ["12", 0], "filename_prefix": "moba_batch"}},
}
def run(prompt, seed):
pid = post("/prompt", {"prompt": workflow(prompt, seed), "client_id": str(uuid.uuid4())})["prompt_id"]
t0 = time.time()
while time.time() - t0 < 600:
hist = json.loads(get("/history/" + pid))
if pid in hist:
st = hist[pid].get("status", {})
if st.get("status_str") == "error":
raise RuntimeError(json.dumps(st)[:900])
for node in hist[pid].get("outputs", {}).values():
for im in node.get("images", []):
q = urllib.parse.urlencode({"filename": im["filename"], "subfolder": im.get("subfolder", ""), "type": im.get("type", "output")})
return Image.open(io.BytesIO(get("/view?" + q))).convert("RGBA")
time.sleep(1.0)
raise TimeoutError("comfy timeout")
def score(im):
"""알파로 채점 — (합격 여부, 불투명 비율, 가장자리 닿음 비율, 가장 큰 덩어리 비율)"""
n = 96
a = im.getchannel("A").resize((n, n), Image.BILINEAR)
px = a.load()
solid = [[px[x, y] > 40 for x in range(n)] for y in range(n)]
total = sum(sum(1 for v in row if v) for row in solid)
if total == 0:
return False, 0.0, 0.0, 0.0
opaque = total / float(n * n)
edge = sum(1 for i in range(n) if solid[0][i]) + sum(1 for i in range(n) if solid[n - 1][i]) + sum(1 for i in range(n) if solid[i][0]) + sum(1 for i in range(n) if solid[i][n - 1])
edge_ratio = edge / float(4 * n)
seen = [[False] * n for _ in range(n)]
best = 0
for y in range(n):
for x in range(n):
if solid[y][x] and not seen[y][x]:
q = deque([(x, y)]); seen[y][x] = True; size = 0
while q:
cx, cy = q.popleft(); size += 1
for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)):
nx, ny = cx + dx, cy + dy
if 0 <= nx < n and 0 <= ny < n and solid[ny][nx] and not seen[ny][nx]:
seen[ny][nx] = True; q.append((nx, ny))
best = max(best, size)
main = best / float(total)
ok = 0.06 <= opaque <= 0.42 and edge_ratio <= 0.015 and main >= 0.88
return ok, opaque, edge_ratio, main
def checker(size, n=16):
im = Image.new("RGB", (size, size), (58, 58, 58))
d = ImageDraw.Draw(im)
for y in range(0, size, n):
for x in range(0, size, n):
if (x // n + y // n) % 2:
d.rectangle([x, y, x + n - 1, y + n - 1], fill=(74, 74, 74))
return im
def main():
out, tag, count = sys.argv[1], sys.argv[2], int(sys.argv[3])
lo = int(sys.argv[4]) if len(sys.argv) > 4 else 0
hi = int(sys.argv[5]) if len(sys.argv) > 5 else len(SUBJECTS)
os.makedirs(out, exist_ok=True)
subs = SUBJECTS[lo:hi]
show = 6
cell, pad, label = 170, 4, 16
sheet = Image.new("RGB", (pad + 110 + show * (cell + pad), len(subs) * (cell + pad) + pad), (40, 40, 40))
draw = ImageDraw.Draw(sheet)
report = []
for i, (name, body) in enumerate(subs):
prompt = "%s, (%s:1.35), no humans, single item, isolated, centered, full object in frame, simple background, still life" % (PREFIX, body)
passed = []
t0 = time.time()
for j in range(count):
im = run(prompt, 12000 + (lo + i) * 29 + j * 503)
ok, opaque, edge, mainc = score(im)
fn = "%s_%s_%02d%s.png" % (tag, name, j, "" if ok else "_x")
im.save(os.path.join(out, fn))
if ok:
passed.append((mainc - abs(opaque - 0.24), fn, im))
passed.sort(key=lambda t: -t[0])
report.append((name, len(passed), count))
print("%s %s 합격 %d/%d (%.0f초)" % (tag, name, len(passed), count, time.time() - t0)); sys.stdout.flush()
y = pad + i * (cell + pad)
draw.text((pad, y + 4), name, fill=(240, 220, 160))
draw.text((pad, y + 22), "%d/%d" % (len(passed), count), fill=(200, 200, 200))
for k, (_, fn, im) in enumerate(passed[:show]):
bg = checker(cell)
small = im.resize((cell, cell), Image.LANCZOS)
bg.paste(small, (0, 0), small)
sheet.paste(bg, (pad + 110 + k * (cell + pad), y))
draw.text((pad + 112 + k * (cell + pad), y + 2), fn[len(tag) + len(name) + 2:len(tag) + len(name) + 4], fill=(255, 255, 255))
path = os.path.join(out, "%s_pass_sheet_%d_%d.png" % (tag, lo, hi))
sheet.save(path)
print("sheet", path)
print("요약", ", ".join("%s %d/%d" % r for r in report))
if __name__ == "__main__":
main()

View File

@ -0,0 +1,106 @@
# -*- coding: utf-8 -*-
# 2026-10-02 PD 지시 — 학습한 FR_moba_v1 LoRA 검증. 학습에 없던 대상(반지 · 방패 · 화살통 · 오브 · 마법책 · 지팡이 · 활 · 표창 등)을 글자만으로 뽑아
# 통일된 풍 · 물건 하나 · 투명 배경으로 나오는지 본다.
# python comfy_moba_lora_test.py <출력폴더> <round 이름> [LoRA 강도=1.0] [시드 수=2]
import io, json, os, sys, time, uuid, urllib.request, urllib.parse
from PIL import Image, ImageDraw
HOST = "http://127.0.0.1:8000"
CKPT = "mobaGameEquipment_v10.safetensors"
LORA = "FR_moba_v1.safetensors"
TRIGGER = "frmoba, moba game icon"
NEG = ("lowres, text, error, cropped, worst quality, low quality, normal quality, jpeg artifacts, signature, watermark, username, blurry, "
"1girl, 1boy, person, human, character, face, circle, round frame, border, badge, emblem, aura, halo, light rays, glowing background, multiple objects, duplicate")
# 앞 8 = 학습에 없던 새 대상(보조 장비 · 반지 등), 뒤 4 = 학습에 있던 부위의 새 디자인
SUBJECTS = [
("ring", "a dark iron ring with an orange glowing gem"),
("shield", "a dark steel kite shield with gold trim"),
("quiver", "a brown leather quiver full of arrows"),
("spellbook", "a closed dark leather spellbook with gold corners and a cyan gem"),
("orb", "a cyan glowing crystal orb held in a dark iron claw"),
("throwing", "a dark steel throwing knife with a ring pommel"),
("staff", "a dark wooden magic staff with a cyan crystal on top"),
("offhand_sword", "a short sword with a gold hilt"),
("sword", "a dark steel longsword with an orange gem in the guard"),
("bow", "a dark wooden recurve bow with gold tips"),
("armor", "a dark plate armor with gold trim and an orange gem"),
("boots", "a pair of dark plate boots with gold trim"),
]
def post(path, data):
req = urllib.request.Request(HOST + path, data=json.dumps(data).encode("utf-8"), headers={"Content-Type": "application/json"})
return json.loads(urllib.request.urlopen(req, timeout=60).read())
def get(path):
return urllib.request.urlopen(HOST + path, timeout=300).read()
def workflow(prompt, seed, strength):
return {
"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": CKPT}},
"2": {"class_type": "LoraLoaderModelOnly", "inputs": {"model": ["1", 0], "lora_name": LORA, "strength_model": strength}},
"3": {"class_type": "CLIPSetLastLayer", "inputs": {"clip": ["1", 1], "stop_at_clip_layer": -2}},
"4": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["3", 0], "text": prompt}},
"5": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["3", 0], "text": NEG}},
"6": {"class_type": "EmptyLatentImage", "inputs": {"width": 512, "height": 512, "batch_size": 1}},
"7": {"class_type": "KSampler", "inputs": {"model": ["2", 0], "positive": ["4", 0], "negative": ["5", 0], "latent_image": ["6", 0], "seed": seed, "steps": 30, "cfg": 7.0, "sampler_name": "dpmpp_2m", "scheduler": "karras", "denoise": 1.0}},
"8": {"class_type": "VAEDecode", "inputs": {"samples": ["7", 0], "vae": ["1", 2]}},
"12": {"class_type": "InspyrenetRembg", "inputs": {"image": ["8", 0], "torchscript_jit": "default"}},
"9": {"class_type": "SaveImage", "inputs": {"images": ["12", 0], "filename_prefix": "moba_lora_test"}},
}
def run(prompt, seed, strength):
pid = post("/prompt", {"prompt": workflow(prompt, seed, strength), "client_id": str(uuid.uuid4())})["prompt_id"]
t0 = time.time()
while time.time() - t0 < 600:
hist = json.loads(get("/history/" + pid))
if pid in hist:
st = hist[pid].get("status", {})
if st.get("status_str") == "error":
raise RuntimeError(json.dumps(st)[:900])
for node in hist[pid].get("outputs", {}).values():
for im in node.get("images", []):
q = urllib.parse.urlencode({"filename": im["filename"], "subfolder": im.get("subfolder", ""), "type": im.get("type", "output")})
return Image.open(io.BytesIO(get("/view?" + q))).convert("RGBA")
time.sleep(1.0)
raise TimeoutError("comfy timeout")
def checker(size, n=16):
im = Image.new("RGB", (size, size), (58, 58, 58))
d = ImageDraw.Draw(im)
for y in range(0, size, n):
for x in range(0, size, n):
if (x // n + y // n) % 2:
d.rectangle([x, y, x + n - 1, y + n - 1], fill=(74, 74, 74))
return im
def main():
out, tag = sys.argv[1], sys.argv[2]
strength = float(sys.argv[3]) if len(sys.argv) > 3 else 1.0
seeds = int(sys.argv[4]) if len(sys.argv) > 4 else 2
os.makedirs(out, exist_ok=True)
cell, pad, label = 160, 4, 16
sheet = Image.new("RGB", (len(SUBJECTS) * (cell + pad) + pad, seeds * (cell + pad) + label + pad), (40, 40, 40))
draw = ImageDraw.Draw(sheet)
for i, (name, desc) in enumerate(SUBJECTS):
prompt = "%s, %s, black background" % (TRIGGER, desc)
draw.text((pad + i * (cell + pad) + 3, 2), name, fill=(240, 220, 160))
for j in range(seeds):
im = run(prompt, 30000 + i * 31 + j * 607, strength)
im.save(os.path.join(out, "%s_%s_%d.png" % (tag, name, j)))
bg = checker(cell); small = im.resize((cell, cell), Image.LANCZOS); bg.paste(small, (0, 0), small)
sheet.paste(bg, (pad + i * (cell + pad), label + j * (cell + pad)))
print("%s %s" % (tag, name)); sys.stdout.flush()
path = os.path.join(out, "%s_sheet.png" % tag)
sheet.save(path)
print("sheet", path)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,108 @@
# -*- coding: utf-8 -*-
# 2026-10-02 PD 지시 — "…합격 기준에 드는 것들을 통일감 있게 만들어. 그리고 나서 최종 결과물을 학습시켜."
# FR 장비 아이콘을 MOBA 풍으로 다시 칠해 고른 그림(comfy_moba_repaint.py → picked/)으로 스타일 LoRA 를 학습한다.
# 1) 데이터셋: 고른 그림(아이콘당 2장)을 검정 바탕 512² 로 합성, 원본 · 좌우 반전 2벌 + 캡션 .txt → C:\Comfyui\input\fr_moba_v1
# 2) 학습: mobaGameEquipment_v10 · LoRA rank 16 · lr 4e-4 · batch 2 · N step(기본 600) → output/loras → models/loras/FR_moba_v1.safetensors
# python comfy_moba_lora_train.py <picked 폴더> [steps]
# 학습 노드 구성 · 입력 이름은 앞선 FR_icons_v1 학습(comfy_lora_train.py · 2026-09-25 실측)과 같다.
import io, json, os, re, shutil, sys, time, uuid, glob, urllib.request
from PIL import Image, ImageOps
HOST = "http://127.0.0.1:8000"
PICKED = sys.argv[1]
STEPS = int(sys.argv[2]) if len(sys.argv) > 2 else 600
DS_NAME = "fr_moba_v1"
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", "FR_moba_v1.safetensors")
CKPT = "mobaGameEquipment_v10.safetensors"
TRIGGER = "frmoba" # 화풍을 묶는 트리거 낱말
RES = 512
LR, RANK, BATCH, SEED = 4e-4, 16, 2, 11
def load_captions():
"""아이콘 번호 → 내용 설명 표는 comfy_lora_train.py 의 CAP 을 그대로 읽는다(표를 두 벌로 두지 않는다)."""
text = io.open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "comfy_lora_train.py"), encoding="utf-8").read()
m = re.search(r"CAP = \{(.*?)\n\}", text, re.S)
return eval("{" + m.group(1) + "}")
def build_dataset():
cap = load_captions()
if os.path.isdir(DS):
# 같은 이름의 예전 데이터셋은 통째로 바꾼다(학습 입력은 다시 만들 수 있는 파생물)
for f in os.listdir(DS):
os.remove(os.path.join(DS, f))
os.makedirs(DS, exist_ok=True)
n = 0
for path in sorted(glob.glob(os.path.join(PICKED, "*.png"))):
base = os.path.splitext(os.path.basename(path))[0] # "<번호>_<순번>"
gid = int(base.split("_")[0])
if gid not in cap:
continue
im = Image.open(path).convert("RGBA").resize((RES, RES), Image.LANCZOS)
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)
bg.save(os.path.join(DS, "%s_%s.png" % (base, tag)))
with io.open(os.path.join(DS, "%s_%s.txt" % (base, tag)), "w", encoding="utf-8") as f:
f.write("%s, moba game icon, %s, black background" % (TRIGGER, cap[gid]))
n += 1
print("dataset %s: %d images" % (DS, n), 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}},
"2": {"class_type": "LoadImageTextDataSetFromFolder", "inputs": {"folder": DS_NAME}},
"3": {"class_type": "MakeTrainingDataset", "inputs": {"images": ["2", 0], "vae": ["1", 2], "clip": ["1", 1], "texts": ["2", 1]}},
"4": {"class_type": "TrainLoraNode", "inputs": {
"model": ["1", 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/FR_moba_v1", "steps": ["4", 2]}},
"6": {"class_type": "LossGraphNode", "inputs": {"loss": ["4", 1], "filename_prefix": "FR_moba_v1_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")[:1500]); return None
if r.get("node_errors"):
print("NODE ERRORS:", json.dumps(r["node_errors"], ensure_ascii=False)[:1500]); 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 < 5400:
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__":
build_dataset()
out = train()
if out is None:
sys.exit(1)
files = sorted(glob.glob(os.path.join(LORA_OUT, "FR_moba_v1*.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))

View File

@ -0,0 +1,193 @@
# -*- coding: utf-8 -*-
# 2026-10-01 PD 지시 — "우리 게임의 장비 아이콘 리소스를 기준으로 moba풍으로 다시 칠하도록 … 로라로 학습 가능한 수준으로 이미지를 뽑아내고
# 합격 기준에 드는 것들을 통일감 있게 만들어. 그리고 나서 최종 결과물을 학습시켜."
# 1단계(이 스크립트): FR 장비 아이콘 원본(형태)을 입력으로 넣고 mobaGameEquipment_v10 이 그 위에 MOBA 풍을 다시 칠한다(이미지 투 이미지).
# 형태는 원본이 정하므로 글자 프롬프트만 쓸 때의 형태 실패가 없다. 다시 칠하는 세기(denoise) 2종 × 시드 3 = 아이콘당 6 후보.
# 배경 제거 뒤 자동 채점: 원본 실루엣과의 겹침(IoU) · 덩어리 하나 · 가장자리 안 닿음. 아이콘마다 점수순으로 한 줄(원본 | 후보)을 만든다.
# python comfy_moba_repaint.py <출력폴더> <round 이름> [시작] [끝] [세기들 "0.5,0.65"] [시드 수=3]
import io, json, os, re, sys, time, uuid, urllib.request, urllib.parse
from collections import deque
from PIL import Image, ImageDraw
HOST = "http://127.0.0.1:8000"
CKPT = "mobaGameEquipment_v10.safetensors"
SRC = r"C:\BurningTimes\FirstRising\Assets\GameAssets\UI\EquipmentItemIcon" # FR 장비 아이콘 원본(1024 · 투명 배경)
PREFIX = "(masterpiece:1.2), (best quality:1.2), moba game icon"
NEG = ("lowres, text, error, cropped, worst quality, low quality, normal quality, jpeg artifacts, signature, watermark, username, blurry, "
"1girl, 1boy, person, human, character, face, "
"circle, round frame, border, badge, emblem, aura, halo, light rays, glowing background, explosion, multiple objects, duplicate")
RES = 512
BG = (16, 16, 20) # 입력 바탕 — 어두운 단색(배경 제거가 깔끔하고, 모델이 원형 바탕을 덜 만든다)
FIT = 0.80 # 캔버스에서 아이콘이 차지하는 비율(여백 확보 — 합격 기준 "전체가 여백 안에")
def load_captions():
"""앞선 학습 스크립트(comfy_lora_train.py)의 CAP 표(아이콘 번호 → 내용 설명)를 그대로 읽는다 — 표를 두 벌로 두지 않는다."""
text = io.open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "comfy_lora_train.py"), encoding="utf-8").read()
m = re.search(r"CAP = \{(.*?)\n\}", text, re.S)
return eval("{" + m.group(1) + "}")
def post(path, data):
req = urllib.request.Request(HOST + path, data=json.dumps(data).encode("utf-8"), headers={"Content-Type": "application/json"})
return json.loads(urllib.request.urlopen(req, timeout=60).read())
def get(path):
return urllib.request.urlopen(HOST + path, timeout=300).read()
def upload(image, name):
"""ComfyUI 입력 폴더로 그림을 올린다(multipart) — LoadImage 가 이 이름으로 읽는다."""
buf = io.BytesIO(); image.save(buf, "PNG"); data = buf.getvalue()
boundary = "----bt" + uuid.uuid4().hex
body = b"".join([
("--%s\r\nContent-Disposition: form-data; name=\"image\"; filename=\"%s\"\r\nContent-Type: image/png\r\n\r\n" % (boundary, name)).encode(), data, b"\r\n",
("--%s\r\nContent-Disposition: form-data; name=\"overwrite\"\r\n\r\ntrue\r\n" % boundary).encode(),
("--%s--\r\n" % boundary).encode()])
req = urllib.request.Request(HOST + "/upload/image", data=body, headers={"Content-Type": "multipart/form-data; boundary=" + boundary})
return json.loads(urllib.request.urlopen(req, timeout=60).read())["name"]
def workflow(image_name, prompt, seed, denoise):
return {
"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": CKPT}},
"3": {"class_type": "CLIPSetLastLayer", "inputs": {"clip": ["1", 1], "stop_at_clip_layer": -2}},
"4": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["3", 0], "text": prompt}},
"5": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["3", 0], "text": NEG}},
"20": {"class_type": "LoadImage", "inputs": {"image": image_name}},
"21": {"class_type": "VAEEncode", "inputs": {"pixels": ["20", 0], "vae": ["1", 2]}},
"7": {"class_type": "KSampler", "inputs": {"model": ["1", 0], "positive": ["4", 0], "negative": ["5", 0], "latent_image": ["21", 0], "seed": seed, "steps": 30, "cfg": 7.0, "sampler_name": "dpmpp_2m", "scheduler": "karras", "denoise": denoise}},
"8": {"class_type": "VAEDecode", "inputs": {"samples": ["7", 0], "vae": ["1", 2]}},
"12": {"class_type": "InspyrenetRembg", "inputs": {"image": ["8", 0], "torchscript_jit": "default"}},
"9": {"class_type": "SaveImage", "inputs": {"images": ["12", 0], "filename_prefix": "moba_repaint"}},
}
def run(image_name, prompt, seed, denoise):
pid = post("/prompt", {"prompt": workflow(image_name, prompt, seed, denoise), "client_id": str(uuid.uuid4())})["prompt_id"]
t0 = time.time()
while time.time() - t0 < 600:
hist = json.loads(get("/history/" + pid))
if pid in hist:
st = hist[pid].get("status", {})
if st.get("status_str") == "error":
raise RuntimeError(json.dumps(st)[:900])
for node in hist[pid].get("outputs", {}).values():
for im in node.get("images", []):
q = urllib.parse.urlencode({"filename": im["filename"], "subfolder": im.get("subfolder", ""), "type": im.get("type", "output")})
return Image.open(io.BytesIO(get("/view?" + q))).convert("RGBA")
time.sleep(1.0)
raise TimeoutError("comfy timeout")
def prepare(path):
"""원본 아이콘을 캔버스 가운데에 여백을 두고 놓는다 — (어두운 바탕에 합성한 RGB, 같은 자리의 RGBA)"""
im = Image.open(path).convert("RGBA")
box = im.getchannel("A").getbbox()
if box:
im = im.crop(box)
scale = RES * FIT / float(max(im.size))
im = im.resize((max(1, int(im.width * scale)), max(1, int(im.height * scale))), Image.LANCZOS)
rgba = Image.new("RGBA", (RES, RES), (0, 0, 0, 0))
rgba.paste(im, ((RES - im.width) // 2, (RES - im.height) // 2), im)
rgb = Image.new("RGB", (RES, RES), BG)
rgb.paste(rgba, (0, 0), rgba)
return rgb, rgba
def mask(im, n=96, th=40):
a = im.getchannel("A").resize((n, n), Image.BILINEAR)
px = a.load()
return [[px[x, y] > th for x in range(n)] for y in range(n)]
def score(result, source_mask):
"""(점수, 겹침 IoU, 가장 큰 덩어리 비율, 가장자리 닿음) — 점수가 높을수록 원본 형태를 지킨 깔끔한 한 덩어리"""
n = len(source_mask)
m = mask(result, n)
inter = union = total = 0
for y in range(n):
for x in range(n):
a, b = m[y][x], source_mask[y][x]
if a: total += 1
if a and b: inter += 1
if a or b: union += 1
if total == 0 or union == 0:
return 0.0, 0.0, 0.0, 1.0
iou = inter / float(union)
edge = (sum(1 for i in range(n) if m[0][i]) + sum(1 for i in range(n) if m[n - 1][i]) + sum(1 for i in range(n) if m[i][0]) + sum(1 for i in range(n) if m[i][n - 1])) / float(4 * n)
seen = [[False] * n for _ in range(n)]
best = 0
for y in range(n):
for x in range(n):
if m[y][x] and not seen[y][x]:
q = deque([(x, y)]); seen[y][x] = True; size = 0
while q:
cx, cy = q.popleft(); size += 1
for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)):
nx, ny = cx + dx, cy + dy
if 0 <= nx < n and 0 <= ny < n and m[ny][nx] and not seen[ny][nx]:
seen[ny][nx] = True; q.append((nx, ny))
best = max(best, size)
main = best / float(total)
s = iou * 0.7 + main * 0.3 - edge * 2.0
return s, iou, main, edge
def checker(size, n=16):
im = Image.new("RGB", (size, size), (58, 58, 58))
d = ImageDraw.Draw(im)
for y in range(0, size, n):
for x in range(0, size, n):
if (x // n + y // n) % 2:
d.rectangle([x, y, x + n - 1, y + n - 1], fill=(74, 74, 74))
return im
def main():
out, tag = sys.argv[1], sys.argv[2]
lo = int(sys.argv[3]) if len(sys.argv) > 3 else 0
hi = int(sys.argv[4]) if len(sys.argv) > 4 else 999
denoises = [float(v) for v in (sys.argv[5] if len(sys.argv) > 5 else "0.5,0.65").split(",")]
seeds = int(sys.argv[6]) if len(sys.argv) > 6 else 3
os.makedirs(out, exist_ok=True)
cap = load_captions()
ids = sorted(cap.keys())[lo:hi]
show = len(denoises) * seeds
cell, pad = 150, 4
sheet = Image.new("RGB", (pad + (show + 1) * (cell + pad), len(ids) * (cell + pad) + pad), (40, 40, 40))
draw = ImageDraw.Draw(sheet)
scores = {}
for i, gid in enumerate(ids):
rgb, rgba = prepare(os.path.join(SRC, "%d.png" % gid))
name = upload(rgb, "bt_moba_src_%d.png" % gid)
src_mask = mask(rgba)
prompt = "%s, (%s:1.2), no humans, single item, isolated, dark background" % (PREFIX, cap[gid])
results = []
t0 = time.time()
for d in denoises:
for j in range(seeds):
im = run(name, prompt, 20000 + gid * 7 + j * 911 + int(d * 100), d)
s, iou, mainc, edge = score(im, src_mask)
fn = "%s_%d_d%02d_%d.png" % (tag, gid, int(d * 100), j)
im.save(os.path.join(out, fn))
results.append((s, fn, im, iou, mainc, edge, d))
results.sort(key=lambda t: -t[0])
scores[gid] = [dict(file=r[1], score=round(r[0], 3), iou=round(r[3], 3), main=round(r[4], 3), edge=round(r[5], 3), denoise=r[6]) for r in results]
print("%s %d 최고 IoU %.2f(세기 %.2f) · %.0f초" % (tag, gid, results[0][3], results[0][6], time.time() - t0)); sys.stdout.flush()
y = pad + i * (cell + pad)
bg = checker(cell); small = rgba.resize((cell, cell), Image.LANCZOS); bg.paste(small, (0, 0), small); sheet.paste(bg, (pad, y))
draw.text((pad + 3, y + 2), "%d 원본" % gid, fill=(255, 255, 255))
for k, r in enumerate(results[:show]):
bg = checker(cell); small = r[2].resize((cell, cell), Image.LANCZOS); bg.paste(small, (0, 0), small)
sheet.paste(bg, (pad + (k + 1) * (cell + pad), y))
draw.text((pad + (k + 1) * (cell + pad) + 3, y + 2), "d%.2f #%s IoU%.2f" % (r[6], r[1][-5], r[3]), fill=(255, 255, 255))
json.dump(scores, io.open(os.path.join(out, "%s_scores_%d_%d.json" % (tag, lo, hi)), "w", encoding="utf-8"), ensure_ascii=False, indent=1)
path = os.path.join(out, "%s_sheet_%d_%d.png" % (tag, lo, hi))
sheet.save(path)
print("sheet", path)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,149 @@
# -*- coding: utf-8 -*-
# 2026-10-02 PD 지적 — 1차 다시 칠하기(comfy_moba_repaint.py · 512) 결과에 "퀄리티 좀 아쉬운데?".
# 원인(직접 본 것): 512 로 줄여 칠해 1024 원본의 세부가 뭉개졌고, 세기를 낮춰(0.5~0.65) MOBA 풍의 질감이 덜 올라왔다.
# 고품질 방식(이 스크립트):
# 1) 원본에서 윤곽선을 뽑아 ControlNet(선화 또는 캐니)으로 형태를 붙잡는다 → 세기를 높여(0.75~0.9) 질감 · 빛을 MOBA 풍으로 새로 칠해도 형태가 남는다.
# 2) 768(모델 권장 최대)에서 칠한 뒤 4x-UltraSharp 로 키우고 1024 로 맞춰, 약한 세기(0.3)로 한 번 더 다듬는다(세부 선명도).
# 3) 배경 제거(Inspyrenet) → 1024 투명 PNG. 채점은 1차와 같다(원본 실루엣 겹침 · 한 덩어리 · 가장자리).
# python comfy_moba_repaint_hq.py <출력폴더> <round 이름> <번호들 "1015,1253" 또는 all> [윤곽 "lineart,canny"] [세기 "0.75,0.9"] [시드 수=1] [윤곽 강도=0.8]
import io, json, os, sys, time, uuid, urllib.parse
from PIL import Image, ImageDraw
import comfy_moba_repaint as base # 올리기 · 채점 · 캡션 표는 1차 스크립트의 것을 그대로 쓴다
CKPT = base.CKPT
PREFIX = "(masterpiece:1.2), (best quality:1.2), moba game icon"
STYLE = "highly detailed, intricate details, sharp focus, polished metal, strong specular highlights, rim light, rich colors, hand painted game equipment icon"
# 시험(h1)에서 갑옷에 소매 · 팔이 덧붙어 몸 관련 낱말을 더했다
NEG = base.NEG + ", flat color, sketch, monochrome, washed out, sleeves, arms, torso, body, mannequin"
RES1, RES2 = 768, 1024 # 1차로 칠하는 크기 · 최종 크기
CN = {"lineart": "control_v11p_sd15_lineart_fp16.safetensors", "canny": "control_v11p_sd15_canny_fp16.safetensors"}
UPSCALER = "4x-UltraSharp.pth"
REFINE = 0.30 # 2차 다듬기 세기
def prepare(path, res):
"""원본 아이콘을 res 캔버스 가운데에 여백을 두고 놓는다 — (어두운 바탕 RGB, 투명 RGBA)"""
im = Image.open(path).convert("RGBA")
box = im.getchannel("A").getbbox()
if box:
im = im.crop(box)
scale = res * base.FIT / float(max(im.size))
im = im.resize((max(1, int(im.width * scale)), max(1, int(im.height * scale))), Image.LANCZOS)
rgba = Image.new("RGBA", (res, res), (0, 0, 0, 0))
rgba.paste(im, ((res - im.width) // 2, (res - im.height) // 2), im)
rgb = Image.new("RGB", (res, res), base.BG)
rgb.paste(rgba, (0, 0), rgba)
return rgb, rgba
def workflow(src768, src1024, prompt, seed, denoise, kind, cn_strength):
"""768 칠하기(ControlNet) → 4배 확대 → 1024 → 약하게 다듬기(ControlNet) → 배경 제거"""
wf = {
"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": CKPT}},
"3": {"class_type": "CLIPSetLastLayer", "inputs": {"clip": ["1", 1], "stop_at_clip_layer": -2}},
"4": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["3", 0], "text": prompt}},
"5": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["3", 0], "text": NEG}},
"20": {"class_type": "LoadImage", "inputs": {"image": src768}},
"22": {"class_type": "LoadImage", "inputs": {"image": src1024}},
"30": {"class_type": "ControlNetLoader", "inputs": {"control_net_name": CN[kind]}},
# 윤곽은 1024 원본에서 뽑는다(가는 선까지 남긴다)
"31": ({"class_type": "LineArtPreprocessor", "inputs": {"image": ["22", 0], "coarse": "disable", "resolution": 1024}} if kind == "lineart"
else {"class_type": "Canny", "inputs": {"image": ["22", 0], "low_threshold": 0.15, "high_threshold": 0.45}}),
"32": {"class_type": "ControlNetApplyAdvanced", "inputs": {"positive": ["4", 0], "negative": ["5", 0], "control_net": ["30", 0], "image": ["31", 0],
"strength": cn_strength, "start_percent": 0.0, "end_percent": 0.85, "vae": ["1", 2]}},
"21": {"class_type": "VAEEncode", "inputs": {"pixels": ["20", 0], "vae": ["1", 2]}},
"7": {"class_type": "KSampler", "inputs": {"model": ["1", 0], "positive": ["32", 0], "negative": ["32", 1], "latent_image": ["21", 0], "seed": seed,
"steps": 30, "cfg": 7.0, "sampler_name": "dpmpp_2m", "scheduler": "karras", "denoise": denoise}},
"8": {"class_type": "VAEDecode", "inputs": {"samples": ["7", 0], "vae": ["1", 2]}},
"40": {"class_type": "UpscaleModelLoader", "inputs": {"model_name": UPSCALER}},
"41": {"class_type": "ImageUpscaleWithModel", "inputs": {"upscale_model": ["40", 0], "image": ["8", 0]}},
"42": {"class_type": "ImageScale", "inputs": {"image": ["41", 0], "upscale_method": "lanczos", "width": RES2, "height": RES2, "crop": "disabled"}},
"43": {"class_type": "VAEEncode", "inputs": {"pixels": ["42", 0], "vae": ["1", 2]}},
"44": {"class_type": "KSampler", "inputs": {"model": ["1", 0], "positive": ["32", 0], "negative": ["32", 1], "latent_image": ["43", 0], "seed": seed + 1,
"steps": 24, "cfg": 7.0, "sampler_name": "dpmpp_2m", "scheduler": "karras", "denoise": REFINE}},
"45": {"class_type": "VAEDecode", "inputs": {"samples": ["44", 0], "vae": ["1", 2]}},
"12": {"class_type": "InspyrenetRembg", "inputs": {"image": ["45", 0], "torchscript_jit": "default"}},
"9": {"class_type": "SaveImage", "inputs": {"images": ["12", 0], "filename_prefix": "moba_hq"}},
}
return wf
def run(wf):
pid = base.post("/prompt", {"prompt": wf, "client_id": str(uuid.uuid4())})["prompt_id"]
t0 = time.time()
while time.time() - t0 < 900:
hist = json.loads(base.get("/history/" + pid))
if pid in hist:
st = hist[pid].get("status", {})
if st.get("status_str") == "error":
raise RuntimeError(json.dumps(st)[:1500])
for node in hist[pid].get("outputs", {}).values():
for im in node.get("images", []):
q = urllib.parse.urlencode({"filename": im["filename"], "subfolder": im.get("subfolder", ""), "type": im.get("type", "output")})
return Image.open(io.BytesIO(base.get("/view?" + q))).convert("RGBA")
time.sleep(1.0)
raise TimeoutError("comfy timeout")
def main():
out, tag = sys.argv[1], sys.argv[2]
cap = base.load_captions()
ids = sorted(cap.keys()) if sys.argv[3] == "all" else [int(v) for v in sys.argv[3].split(",")]
kinds = (sys.argv[4] if len(sys.argv) > 4 else "lineart,canny").split(",")
denoises = [float(v) for v in (sys.argv[5] if len(sys.argv) > 5 else "0.75,0.9").split(",")]
seeds = int(sys.argv[6]) if len(sys.argv) > 6 else 1
cn_strength = float(sys.argv[7]) if len(sys.argv) > 7 else 0.8
old_dir = sys.argv[8] if len(sys.argv) > 8 else "" # 1차 결과(고른 것) 폴더 — 있으면 비교 칸으로 넣는다
os.makedirs(out, exist_ok=True)
show = len(kinds) * len(denoises) * seeds
extra = 2 if old_dir else 1
cell, pad = 256, 4
sheet = Image.new("RGB", (pad + (show + extra) * (cell + pad), len(ids) * (cell + pad) + pad), (40, 40, 40))
draw = ImageDraw.Draw(sheet)
scores = {}
def put(im, col, y, text):
bg = base.checker(cell); small = im.resize((cell, cell), Image.LANCZOS); bg.paste(small, (0, 0), small)
sheet.paste(bg, (pad + col * (cell + pad), y))
draw.text((pad + col * (cell + pad) + 3, y + 2), text, fill=(255, 255, 255))
for i, gid in enumerate(ids):
path = os.path.join(base.SRC, "%d.png" % gid)
rgb1, _ = prepare(path, RES1)
rgb2, rgba2 = prepare(path, RES2)
n1 = base.upload(rgb1, "bt_moba_hq768_%d.png" % gid)
n2 = base.upload(rgb2, "bt_moba_hq1024_%d.png" % gid)
src_mask = base.mask(rgba2)
prompt = "%s, (%s:1.2), %s, no humans, single item, isolated, dark background" % (PREFIX, cap[gid], STYLE)
y = pad + i * (cell + pad)
put(rgba2, 0, y, "%d 원본" % gid)
col = 1
if old_dir:
old = os.path.join(old_dir, "%d_0.png" % gid)
if os.path.exists(old):
put(Image.open(old).convert("RGBA"), 1, y, "1차(512)")
col = 2
results = []
t0 = time.time()
for kind in kinds:
for d in denoises:
for j in range(seeds):
fn = "%s_%d_%s_d%02d_%d.png" % (tag, gid, kind, int(d * 100), j)
# 이미 만든 그림은 다시 만들지 않는다(긴 작업이 끊겨도 이어서 돌린다)
if os.path.exists(os.path.join(out, fn)):
im = Image.open(os.path.join(out, fn)).convert("RGBA")
else:
im = run(workflow(n1, n2, prompt, 40000 + gid * 7 + j * 911 + int(d * 100), d, kind, cn_strength))
im.save(os.path.join(out, fn))
s, iou, mainc, edge = base.score(im, src_mask)
results.append(dict(file=fn, score=round(s, 3), iou=round(iou, 3), main=round(mainc, 3), edge=round(edge, 3), denoise=d, kind=kind))
put(im, col, y, "%s d%.2f #%d IoU%.2f" % (kind, d, j, iou)); col += 1
scores[gid] = results
print("%s %d · %d장 · %.0f초" % (tag, gid, len(results), time.time() - t0)); sys.stdout.flush()
sheet.save(os.path.join(out, "%s_sheet.png" % tag))
json.dump(scores, io.open(os.path.join(out, "%s_scores.json" % tag), "w", encoding="utf-8"), ensure_ascii=False, indent=1)
print("sheet", os.path.join(out, "%s_sheet.png" % tag))
if __name__ == "__main__":
main()

View File

@ -0,0 +1,48 @@
# -*- coding: utf-8 -*-
# 2026-10-02 PD 지시(자율 반복) — PD 가 직접 동그라미 친 그림만 모아 "합격 기준 시트"를 만든다. 검토 담당(사람 · 에이전트)이 새 그림을 고를 때 이 시트를 기준으로 삼는다.
# python icon_approved_sheet.py
# 목록은 PD 가 고른 그대로다(1차 = a1 시트의 동그라미 7장 · 2차 = final_sheet 의 동그라미 8장). PD 가 새로 고르면 여기에 더한다.
import os
from PIL import Image, ImageDraw
HERE = os.path.dirname(os.path.abspath(__file__))
A1 = r"C:\Users\sw\AppData\Local\Temp\claude\E--BurningTimes\87b07a14-ef05-46d9-b47a-a879d51eee93\scratchpad\moba_anfg"
OUT = os.path.join(HERE, "moba_anfg", "approved")
PICKS = [
# (출력 이름, 원본 경로)
("pd1_ring", os.path.join(A1, "a1_ring_s10_0.png")),
("pd1_helmet", os.path.join(A1, "a1_helmet_s07_0.png")),
("pd1_gloves_a", os.path.join(A1, "a1_gloves_s07_1.png")),
("pd1_gloves_b", os.path.join(A1, "a1_gloves_s10_0.png")),
("pd1_armor", os.path.join(A1, "a1_armor_s07_1.png")),
("pd1_necklace", os.path.join(A1, "a1_necklace_s07_0.png")),
("pd1_boots", os.path.join(A1, "a1_boots_s10_0.png")),
("pd2_armor", os.path.join(HERE, "moba_anfg", "final", "armor_a.png")),
("pd2_boots", os.path.join(HERE, "moba_anfg", "final", "boots_a.png")),
("pd2_dagger", os.path.join(HERE, "moba_anfg", "final", "dagger_c.png")),
("pd2_magicdagger", os.path.join(HERE, "moba_anfg", "final", "magicdagger_b.png")),
("pd2_necklace", os.path.join(HERE, "moba_anfg", "final", "necklace_a.png")),
("pd2_ring", os.path.join(HERE, "moba_anfg", "final", "ring_c.png")),
("pd2_shield", os.path.join(HERE, "moba_anfg", "final", "shield_b.png")),
("pd2_sword", os.path.join(HERE, "moba_anfg", "final", "sword_a.png")),
]
def main():
os.makedirs(OUT, exist_ok=True)
cell, pad, cols = 256, 4, 5
rows = (len(PICKS) + cols - 1) // cols
sheet = Image.new("RGB", (cols * (cell + pad) + pad, rows * (cell + pad) + pad), (24, 24, 28))
draw = ImageDraw.Draw(sheet)
for i, (name, path) in enumerate(PICKS):
im = Image.open(path).convert("RGBA")
im.save(os.path.join(OUT, name + ".png")) # 원본을 합격본 폴더에 사본으로 모아 둔다(scratchpad 는 사라질 수 있다)
bg = Image.new("RGB", (cell, cell), (40, 40, 46)); small = im.resize((cell, cell), Image.LANCZOS); bg.paste(small, (0, 0), small)
x, y = pad + (i % cols) * (cell + pad), pad + (i // cols) * (cell + pad)
sheet.paste(bg, (x, y)); draw.text((x + 3, y + 2), name, fill=(220, 220, 220))
sheet.save(os.path.join(OUT, "approved_sheet.png"))
print("approved %d -> %s" % (len(PICKS), OUT))
if __name__ == "__main__":
main()

View File

@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
# 2026-10-02 PD 지시(자율 반복) — 같은 길잡이의 "지금 색 조합(밝은 금 + 적/청)"과 "어두운 색 조합(a22)"을 나란히 놓은 비교 시트(PD 방향 결정용).
# python icon_compare_sheet.py
# 열: 지금(앞 회차의 같은 행 #0) | 어두운 조합 ANFG 0.7 #0 · #1 | ANFG 0.8 #0 · #1
import os
from PIL import Image, ImageDraw
ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "moba_anfg")
ROWS = [("armor-fr1267", "a17"), ("boots-fr1379", "a17"), ("gloves-fr1183", "a17"), ("necklace-fr1099", "a17"), ("belt-fr1351", "a17"),
("helmet-genmoba0", "a17"), ("blunt-fr1057", "a17"), ("shield-genxl1", "a19"), ("sword-genxl1", "a19"), ("staff-genxlb0", "a20")]
HEAD = ["now (bright)", "dark 0.7 #0", "dark 0.7 #1", "dark 0.8 #0", "dark 0.8 #1"]
def main():
cell, pad, top = 200, 4, 18
sheet = Image.new("RGB", (len(HEAD) * (cell + pad) + pad, top + len(ROWS) * (cell + pad) + pad), (24, 24, 28))
d = ImageDraw.Draw(sheet)
for c, h in enumerate(HEAD):
d.text((pad + c * (cell + pad) + 4, 3), h, fill=(240, 220, 160))
for r, (row, old) in enumerate(ROWS):
files = ["%s_%s_s07_0.png" % (old, row), "a22_%s_s07_0.png" % row, "a22_%s_s07_1.png" % row, "a22_%s_s08_0.png" % row, "a22_%s_s08_1.png" % row]
for c, fn in enumerate(files):
path = os.path.join(ROOT, fn)
if not os.path.exists(path):
continue
im = Image.open(path).convert("RGBA")
# 게임 칸과 비슷한 어두운 바탕
bg = Image.new("RGB", (cell, cell), (40, 40, 46)); small = im.resize((cell, cell), Image.LANCZOS); bg.paste(small, (0, 0), small)
sheet.paste(bg, (pad + c * (cell + pad), top + r * (cell + pad)))
out = os.path.join(ROOT, "a22_compare.png")
sheet.save(out); print(out)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,66 @@
# -*- coding: utf-8 -*-
# 2026-10-02 PD 지시(자율 반복) — "완성 된 이미지를 모아 학습을 거듭해 … 체계를 구축". 합격본을 학습 데이터셋 목록(dataset.json)에 쌓는다.
# python icon_dataset.py add <고른 목록 json> <그림 폴더> <출처: pd|auto> 고른 그림을 목록에 더한다(같은 파일은 한 번만)
# python icon_dataset.py list 목록 요약
# 고른 목록 json: {"<이름>": "<파일 이름>", …}. 파일 이름 규칙 <round>_<대상>_s<강도>_<순번>.png 에서 대상을 읽어 묘사(캡션)를 찾는다.
# 그림은 moba_anfg/dataset/ 로 사본을 모은다(1024 투명 · 여백 통일 = icon_finalize.finalize).
import io, json, os, re, sys
import comfy_moba_anfg as gen
from icon_finalize import finalize
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.join(HERE, "moba_anfg")
DS_DIR = os.path.join(ROOT, "dataset")
DS_JSON = os.path.join(ROOT, "dataset.json")
def table_for(tag):
"""회차 이름 → 그 회차가 쓴 대상 표. 같은 이름(ring 등)이라도 회차마다 묘사가 다르므로 회차로 표를 고른다.
(2026-10-02 수정: 처음에는 표를 합쳐 찾아 a2 그림에 a1 묘사가 붙었다 — v1 학습은 그 잘못된 캡션으로 돌았다.)"""
if tag in ("a1", "a4"):
table = gen.SUBJECTS
elif tag in ("a2", "a5n", "a5v"):
table = gen.SUBJECTS_FR
elif tag == "a3":
table = gen.SUBJECTS_SHAPE
else:
table = gen.SUBJECTS_ALL
return dict((x[0], x[1]) for x in table)
def load():
return json.load(io.open(DS_JSON, encoding="utf-8")) if os.path.exists(DS_JSON) else []
def add(picks_path, folder, source):
picks = json.load(io.open(picks_path, encoding="utf-8"))
data = load()
have = set(e["src"] for e in data)
os.makedirs(DS_DIR, exist_ok=True)
n = 0
for name, fn in sorted(picks.items()):
if fn in have:
continue
m = re.match(r"^([a-z0-9]+)_(.+)_s\d+_\d+\.png$", fn)
if not m:
print("이름 규칙이 아니라 건너뜀:", fn); continue
tag, subject = m.group(1), m.group(2)
# 생성 때 남긴 곁파일(<그림>.txt)이 있으면 그 묘사를 쓴다. 없으면(예전 회차) 회차의 대상 표에서 찾는다.
side = os.path.join(folder, fn[:-4] + ".txt")
caption = io.open(side, encoding="utf-8").read().strip() if os.path.exists(side) else table_for(tag).get(subject)
if not caption:
print("묘사를 못 찾아 건너뜀:", fn); continue
out_name = "%s_%s.png" % (tag, fn[len(tag) + 1:-4])
finalize(os.path.join(folder, fn)).save(os.path.join(DS_DIR, out_name))
data.append({"file": out_name, "src": fn, "caption": caption, "source": source})
n += 1
json.dump(data, io.open(DS_JSON, "w", encoding="utf-8"), ensure_ascii=False, indent=1)
print("추가 %d · 전체 %d (pd %d)" % (n, len(data), sum(1 for e in data if e["source"] == "pd")))
if __name__ == "__main__":
if sys.argv[1] == "add":
add(sys.argv[2], sys.argv[3], sys.argv[4])
else:
data = load()
print("전체 %d · pd %d" % (len(data), sum(1 for e in data if e["source"] == "pd")))

View File

@ -0,0 +1,48 @@
# -*- coding: utf-8 -*-
# 2026-10-02 PD 지시 — "기준에 맞는지 검토해서 배경을 투명하게 … 리소스화 할 수 있도록 제작".
# 검토에서 고른 그림을 게임 리소스 규격으로 다듬는다: 옅은 잔여 알파 제거 → 물체 테두리로 잘라 → 캔버스(1024) 가운데에 같은 여백(물체 긴 변 = 캔버스의 82%)으로 놓는다.
# python icon_finalize.py <원본 폴더> <고른 목록 json> <출력 폴더>
# 목록 json: {"<출력 이름>": "<원본 파일 이름>", …}. 끝에 고른 것 전체를 한 장(final_sheet.png)으로 묶는다.
import io, json, os, sys
from PIL import Image, ImageDraw
SIZE, FIT = 1024, 0.82 # FR 원본 아이콘과 같은 1024 · 여백 통일
ALPHA_FLOOR = 24 # 이보다 옅은 알파(배경 제거가 남긴 안개)는 지운다
def finalize(path):
im = Image.open(path).convert("RGBA")
a = im.getchannel("A").point(lambda v: 0 if v < ALPHA_FLOOR else v)
im.putalpha(a)
box = a.getbbox()
if box:
im = im.crop(box)
scale = SIZE * FIT / float(max(im.size))
im = im.resize((max(1, int(round(im.width * scale))), max(1, int(round(im.height * scale)))), Image.LANCZOS)
canvas = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
canvas.paste(im, ((SIZE - im.width) // 2, (SIZE - im.height) // 2), im)
return canvas
def main():
src, picks_path, out = sys.argv[1], sys.argv[2], sys.argv[3]
picks = json.load(io.open(picks_path, encoding="utf-8"))
os.makedirs(out, exist_ok=True)
names = sorted(picks.keys())
cell, pad, cols = 200, 4, 8
rows = (len(names) + cols - 1) // cols
sheet = Image.new("RGB", (cols * (cell + pad) + pad, rows * (cell + pad) + pad), (24, 24, 28))
draw = ImageDraw.Draw(sheet)
for i, name in enumerate(names):
im = finalize(os.path.join(src, picks[name]))
im.save(os.path.join(out, name + ".png"))
# 게임 칸과 비슷한 어두운 바탕에 올려 본다
bg = Image.new("RGB", (cell, cell), (40, 40, 46)); small = im.resize((cell, cell), Image.LANCZOS); bg.paste(small, (0, 0), small)
x, y = pad + (i % cols) * (cell + pad), pad + (i // cols) * (cell + pad)
sheet.paste(bg, (x, y)); draw.text((x + 3, y + 2), name, fill=(220, 220, 220))
sheet.save(os.path.join(out, "final_sheet.png"))
print("%d icons -> %s" % (len(names), out))
if __name__ == "__main__":
main()

View File

@ -0,0 +1,98 @@
# -*- coding: utf-8 -*-
# 2026-10-02 PD 지시(자율 반복) — 길잡이가 없는(또는 직접 그린 도형으로는 안 되는) 종류의 길잡이를 자동으로 마련한다.
# a15 검토: 도끼는 도형을 네 번 고쳐도 "속 빈 초승달"(선화가 면을 고리로 읽는다). 투구 · 반지 · 뿔피리 · 구슬도 단순 도형에서는 매끈하게만 나온다.
# → 기반 모델(mobaGameEquipment_v10 · LoRA 없음)이 글자로 그린 그림 가운데 "물건 하나 · 여백 안 · 한 덩어리"인 것을 골라 그 윤곽을 길잡이로 쓴다.
# 안쪽 선이 풍부해 모델이 채울 근거가 생긴다. 색 · 질감은 넘어가지 않는다(윤곽선만 쓴다).
# python icon_guide_bootstrap.py <종류> "<묘사>" [장수=10] [고를 수=2] ["부정어"]
# 결과: moba_anfg/shapes/<종류>__genmoba<k>.png (active.txt 에 등록) · 후보 전체는 moba_anfg/bootstrap/ 에 남긴다.
import io, json, os, sys, time, uuid, urllib.parse
from PIL import Image
import comfy_moba_batch as mb
import icon_shapes
# a17 · a18 검토: 기반 모델(SD1.5)의 초벌은 단검이 두 자루로, 반지 · 뿔피리가 다른 물건으로 나와 길잡이로 쓸 수 없었다.
# → 환경변수 BOOT_MODEL=xl 이면 글자를 더 잘 알아듣는 SDXL 아이콘 모델(gameIconInstitute_v4XL)로 초벌을 그린다. 풍은 상관없다(윤곽만 쓴다). 등록 이름은 genxl<k>.
XL = os.environ.get("BOOT_MODEL", "") == "xl"
XL_CKPT = "gameIconInstitute_v4XL.safetensors"
XL_NEG = "worst quality, low quality, lowres, blurry, cropped, cut off, text, watermark, signature, person, hands, multiple objects, duplicate, frame, border, round background"
def run_xl(desc, seed):
"""SDXL 초벌 한 장(1024 · 배경 제거)"""
wf = {
"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": XL_CKPT}},
"3": {"class_type": "CLIPSetLastLayer", "inputs": {"clip": ["1", 1], "stop_at_clip_layer": -2}},
"4": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["3", 0], "text": "gmic_\\(3dicon\\), game icon, %s, single object, centered, whole object visible, wide margin, simple dark background" % desc}},
"5": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["3", 0], "text": XL_NEG}},
"6": {"class_type": "EmptyLatentImage", "inputs": {"width": 1024, "height": 1024, "batch_size": 1}},
"7": {"class_type": "KSampler", "inputs": {"model": ["1", 0], "positive": ["4", 0], "negative": ["5", 0], "latent_image": ["6", 0], "seed": seed, "steps": 20, "cfg": 7.0, "sampler_name": "dpmpp_2m", "scheduler": "karras", "denoise": 1.0}},
"8": {"class_type": "VAEDecode", "inputs": {"samples": ["7", 0], "vae": ["1", 2]}},
"12": {"class_type": "InspyrenetRembg", "inputs": {"image": ["8", 0], "torchscript_jit": "default"}},
"9": {"class_type": "SaveImage", "inputs": {"images": ["12", 0], "filename_prefix": "boot_xl"}},
}
pid = mb.post("/prompt", {"prompt": wf, "client_id": str(uuid.uuid4())})["prompt_id"]
t0 = time.time()
while time.time() - t0 < 900:
hist = json.loads(mb.get("/history/" + pid))
if pid in hist:
st = hist[pid].get("status", {})
if st.get("status_str") == "error":
raise RuntimeError(json.dumps(st)[:1200])
for node in hist[pid].get("outputs", {}).values():
for im in node.get("images", []):
q = urllib.parse.urlencode({"filename": im["filename"], "subfolder": im.get("subfolder", ""), "type": im.get("type", "output")})
return Image.open(io.BytesIO(mb.get("/view?" + q))).convert("RGBA")
time.sleep(1.5)
raise TimeoutError("comfy timeout")
HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(HERE, "moba_anfg", "bootstrap")
def main():
kind, desc = sys.argv[1], sys.argv[2]
count = int(sys.argv[3]) if len(sys.argv) > 3 else 10
keep = int(sys.argv[4]) if len(sys.argv) > 4 else 2
extra_neg = sys.argv[5] if len(sys.argv) > 5 else ""
os.makedirs(OUT, exist_ok=True)
global XL_NEG
if extra_neg:
mb.NEG = mb.NEG + ", " + extra_neg
XL_NEG = XL_NEG + ", " + extra_neg
prompt = "%s, (%s:1.35), no humans, single item, isolated, centered, full object in frame, simple background, still life" % (mb.PREFIX, desc)
passed = []
for j in range(count):
seed = 77000 + sum(ord(c) for c in kind) * 13 + j * 503
im = run_xl(desc, seed) if XL else mb.run(prompt, seed)
ok, opaque, edge, mainc = mb.score(im)
fn = "%s%s_%02d%s.png" % ("xl_" if XL else "", kind, j, "" if ok else "_x")
im.save(os.path.join(OUT, fn))
if ok:
# 한 덩어리이고 적당한 크기일수록 앞에 둔다
passed.append((mainc - abs(opaque - 0.24), fn))
passed.sort(reverse=True)
# a19 검토: 자동 채점은 "두 자루 · 칼집 딸림 · 다른 물건(뿔피리 → 확성기)"을 못 거른다 → 초벌 전체를 한 장으로 묶어 두고,
# 고를 수(keep)를 0 으로 주면 등록하지 않는다. 그때는 초벌 시트를 보고(사람 · 검토 에이전트) 고른 것을 `python icon_shapes.py add <종류> <파일> <이름>` 으로 등록한다.
prefix = ("xl_" if XL else "") + kind + "_"
names = sorted(f for f in os.listdir(OUT) if f.startswith(prefix) and f.endswith(".png") and "sheet" not in f)
cell, pad, cols = 256, 4, 6
rows = (len(names) + cols - 1) // cols
from PIL import ImageDraw
sheet = Image.new("RGB", (cols * (cell + pad) + pad, rows * (cell + pad) + pad), (24, 24, 28))
d = ImageDraw.Draw(sheet)
for i, fn in enumerate(names):
im = Image.open(os.path.join(OUT, fn)).convert("RGBA")
bg = Image.new("RGB", (cell, cell), (52, 52, 58)); small = im.resize((cell, cell), Image.LANCZOS); bg.paste(small, (0, 0), small)
x, y = pad + (i % cols) * (cell + pad), pad + (i // cols) * (cell + pad)
sheet.paste(bg, (x, y)); d.text((x + 3, y + 2), fn[:-4], fill=(255, 120, 120) if fn.endswith("_x.png") else (120, 255, 120))
sheet.save(os.path.join(OUT, prefix + "sheet.png"))
# 이미 등록된 genmoba 번호 뒤에 잇는다
label = "genxl" if XL else "genmoba"
have = len([f for f in os.listdir(icon_shapes.OUT) if f.startswith(kind + "__" + label)])
for k, (_, fn) in enumerate(passed[:keep]):
icon_shapes.add_from(kind, os.path.join(OUT, fn), "%s%d" % (label, have + k))
print("%s: 통과 %d/%d · 길잡이 등록 %d (%s)" % (kind, len(passed), count, min(keep, len(passed)), ", ".join(fn for _, fn in passed[:keep])))
if __name__ == "__main__":
main()

View File

@ -0,0 +1,26 @@
# -*- coding: utf-8 -*-
# 2026-10-02 PD 지시(자율 반복) — 검토에서 고른 초벌을 길잡이로 등록한다.
# python icon_guides_register.py <guides json 이름> <이름표(예: xlb)>
# guides json: {"<종류>": ["xl_axe_01.png", …], …} (moba_anfg/ 아래 · 초벌은 moba_anfg/bootstrap/ 에 있다)
# 등록 이름 = <종류>__gen<이름표><순번>.png. 마지막 줄에 본 생성에 넘길 행 이름(ANFG_ROWS)을 찍는다.
import io, json, os, sys
import icon_shapes
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.join(HERE, "moba_anfg")
def main():
guides = json.load(io.open(os.path.join(ROOT, sys.argv[1]), encoding="utf-8"))
label = sys.argv[2]
rows = []
for kind, files in sorted(guides.items()):
for k, fn in enumerate(files):
name = "gen%s%d" % (label, k)
icon_shapes.add_from(kind, os.path.join(ROOT, "bootstrap", fn), name)
rows.append("%s-%s" % (kind, name))
print(",".join(rows))
if __name__ == "__main__":
main()

View File

@ -0,0 +1,46 @@
# -*- coding: utf-8 -*-
# 2026-10-02 PD 지시(자율 반복) — "원하는 장비 아이콘을 자유롭게 생성할 수 있는 … 체계". 새 종류든 기존 종류든 한 줄로 뽑는 명령.
# a18 결론: 글자만으로는 형태가 안정되지 않는다 → 정식 경로를 "글자 → 자동 길잡이 → 본 생성" 2단으로 묶는다.
# python icon_make.py <종류> "<초벌 묘사(영어)>" "<본 묘사(영어 · {pal} 자리에 색 조합이 들어간다)>" [초벌 장수=6] [길잡이 수=2] [길잡이당 장수=3]
# 예: python icon_make.py crossbow "a single crossbow, wooden stock, steel limbs" "a crossbow, carved wooden stock, steel limbs with ornate fittings, {pal}"
# 하는 일:
# 1) SDXL 아이콘 모델로 초벌을 그려 자동 채점(물건 하나 · 여백 안 · 한 덩어리)을 통과한 것의 윤곽을 길잡이로 등록(shapes/<종류>__genxl<k>.png)
# 2) 본 묘사를 종류 표(moba_anfg/type_desc.json)에 적는다 — 생성 스크립트가 읽는다
# 3) 그 길잡이로 본 생성(기본 설정 = v4 0.6 · paint · UltraSharp · 다듬기 0.45) → moba_anfg/mk<종류>_*.png + 검토 시트
# 결과는 검토(사람 또는 검토 에이전트)를 거쳐 icon_dataset.py · icon_pack.py 로 넘긴다. 자동 채점은 "그 종류로 읽히는가"를 보지 못한다.
import io, json, os, re, subprocess, sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.join(HERE, "moba_anfg")
DESC_JSON = os.path.join(ROOT, "type_desc.json")
def main():
kind, draft_desc, final_desc = sys.argv[1], sys.argv[2], sys.argv[3]
n_draft = sys.argv[4] if len(sys.argv) > 4 else "6"
n_guide = sys.argv[5] if len(sys.argv) > 5 else "2"
n_out = sys.argv[6] if len(sys.argv) > 6 else "3"
if not re.match(r"^[a-z_]+$", kind):
print("종류 이름은 영어 소문자와 _ 만"); sys.exit(1)
# 2) 본 묘사 등록
table = json.load(io.open(DESC_JSON, encoding="utf-8")) if os.path.exists(DESC_JSON) else {}
table[kind] = final_desc
json.dump(table, io.open(DESC_JSON, "w", encoding="utf-8"), ensure_ascii=False, indent=1)
env = dict(os.environ, PYTHONIOENCODING="utf-8")
# 1) 초벌 → 길잡이
r = subprocess.run([sys.executable, os.path.join(HERE, "icon_guide_bootstrap.py"), kind, draft_desc, n_draft, n_guide], env=dict(env, BOOT_MODEL="xl"), cwd=HERE)
if r.returncode != 0:
sys.exit(r.returncode)
# 3) 본 생성(이 종류의 SDXL 초벌 길잡이만)
tag = "mk" + kind.replace("_", "")
genv = dict(env, ANFG_TONE="paint", ANFG_UPSCALE="model", ANFG_REFINE="0.45", ANFG_LORA2="FR_anfg_v4.safetensors:0.6",
ANFG_ONLY=kind, ANFG_SKIP="genpd,genmoba,draw,-fr,-ll", ANFG_ROWS="", ANFG_SEED=os.environ.get("ANFG_SEED", "41"))
r = subprocess.run([sys.executable, os.path.join(HERE, "comfy_moba_anfg.py"), ROOT, tag, "0.7", n_out, "512", "1", "lib", "0.6", "0.5"], env=genv, cwd=HERE)
if r.returncode != 0:
sys.exit(r.returncode)
subprocess.run([sys.executable, os.path.join(HERE, "icon_review_sheet.py"), ROOT, tag, "8"], env=env, cwd=HERE)
print("끝: %s\\%s_review_0.png 를 검토한 뒤 picks_%s.json → icon_dataset.py add · icon_pack.py" % (ROOT, tag, tag))
if __name__ == "__main__":
main()

View File

@ -0,0 +1,51 @@
# -*- coding: utf-8 -*-
# 2026-10-02 PD 지시(자율 반복) — 검토를 통과한 그림을 리소스 묶음으로 만든다.
# python icon_pack.py <출력 폴더 이름> <picks json 이름들 쉼표로> [시트 칸 수=10]
# 예: python icon_pack.py final_v4 picks_a13.json,picks_a14.json,picks_a17.json
# 결과: moba_anfg/<출력 폴더>/<종류>_<번호>.png (1024 투명 · 여백 통일) + final_sheet.png(종류 순서로 정렬)
import io, json, os, re, sys
from PIL import Image, ImageDraw
from icon_finalize import finalize
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.join(HERE, "moba_anfg")
# 시트에 놓는 순서: 무기 → 방어구 → 장신구 → 보조 장비
ORDER = ["sword", "dagger", "magic_dagger", "offhand_sword", "axe", "blunt", "hammer", "bow", "staff", "armor", "helmet", "gloves", "belt", "boots",
"ring", "necklace", "shield", "quiver", "spellbook", "throwing", "orb", "totem", "horn"]
def kind_of(fn):
m = re.match(r"^[a-z0-9]+_(.+)_s\d+_\d+\.png$", fn)
return m.group(1).split("-")[0] if m else "etc"
def main():
out = os.path.join(ROOT, sys.argv[1])
cols = int(sys.argv[3]) if len(sys.argv) > 3 else 10
files = []
for name in sys.argv[2].split(","):
files += list(json.load(io.open(os.path.join(ROOT, name), encoding="utf-8")).values())
files = sorted(set(files), key=lambda f: (ORDER.index(kind_of(f)) if kind_of(f) in ORDER else 99, f))
os.makedirs(out, exist_ok=True)
cell, pad = 180, 4
rows = (len(files) + cols - 1) // cols
sheet = Image.new("RGB", (cols * (cell + pad) + pad, rows * (cell + pad) + pad), (24, 24, 28))
draw = ImageDraw.Draw(sheet)
count, index = {}, []
for i, fn in enumerate(files):
k = kind_of(fn)
count[k] = count.get(k, 0) + 1
name = "%s_%02d" % (k, count[k])
im = finalize(os.path.join(ROOT, fn))
im.save(os.path.join(out, name + ".png"))
index.append({"icon": name + ".png", "source": fn})
bg = Image.new("RGB", (cell, cell), (40, 40, 46)); small = im.resize((cell, cell), Image.LANCZOS); bg.paste(small, (0, 0), small)
x, y = pad + (i % cols) * (cell + pad), pad + (i // cols) * (cell + pad)
sheet.paste(bg, (x, y)); draw.text((x + 3, y + 2), name, fill=(220, 220, 220))
sheet.save(os.path.join(out, "final_sheet.png"))
json.dump(index, io.open(os.path.join(out, "index.json"), "w", encoding="utf-8"), ensure_ascii=False, indent=1)
print("%d icons -> %s · 종류별 %s" % (len(files), out, sorted(count.items(), key=lambda x: -x[1])))
if __name__ == "__main__":
main()

View File

@ -0,0 +1,41 @@
# -*- coding: utf-8 -*-
# 2026-10-02 PD 지시 — "피드백 해준 기준에 맞는지 검토해서 배경을 투명하게 … 리소스화". 생성한 아이콘 폴더를 채점해 검토용 시트로 묶는다.
# python icon_review_sheet.py <폴더> <round 이름(파일 머리말)> [한 시트의 줄 수=10]
# 파일 이름 규칙: <round>_<대상>_s<강도>_<순번>.png. 채점은 comfy_moba_batch.score(불투명 비율 · 가장자리 닿음 · 한 덩어리).
import glob, os, sys
from PIL import Image, ImageDraw
from comfy_moba_batch import score
def main():
folder, tag = sys.argv[1], sys.argv[2]
rows_per = int(sys.argv[3]) if len(sys.argv) > 3 else 10
groups, order = {}, []
for path in sorted(glob.glob(os.path.join(folder, tag + "_*_s*_*.png")), key=os.path.getmtime):
name = os.path.basename(path)[len(tag) + 1:].rsplit("_s", 1)[0]
if name not in groups:
groups[name] = []; order.append(name)
groups[name].append(path)
cell, pad, left = 240, 4, 90
cols = max(len(v) for v in groups.values())
for page in range(0, len(order), rows_per):
names = order[page:page + rows_per]
sheet = Image.new("RGB", (left + cols * (cell + pad) + pad, len(names) * (cell + pad) + pad), (24, 24, 28))
draw = ImageDraw.Draw(sheet)
for i, name in enumerate(names):
y = pad + i * (cell + pad)
draw.text((pad, y + 4), name, fill=(240, 220, 160))
for k, path in enumerate(sorted(groups[name])):
im = Image.open(path).convert("RGBA")
ok, opaque, edge, mainc = score(im)
# 투명 배경 확인용으로 중간 회색 바탕에 올린다
bg = Image.new("RGB", (cell, cell), (52, 52, 58)); small = im.resize((cell, cell), Image.LANCZOS); bg.paste(small, (0, 0), small)
x = left + k * (cell + pad)
sheet.paste(bg, (x, y))
draw.text((x + 3, y + 2), "#%d %s op%.2f ed%.2f mn%.2f" % (k, "OK" if ok else "X", opaque, edge, mainc), fill=(120, 255, 120) if ok else (255, 120, 120))
out = os.path.join(folder, "%s_review_%d.png" % (tag, page // rows_per))
sheet.save(out); print(out)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,529 @@
# -*- coding: utf-8 -*-
# 2026-10-02 PD 지시(자율 반복) — 형태 길잡이 모음을 만든다.
# a6 검토(에이전트가 직접 봄): 글자만(T)은 21종 중 4종만 합격, 실사풍 초안(D)은 물건 여러 개 · 인물이 섞임, "깨끗한 윤곽을 빌린 것(S)"은 8장 모두 형태가 읽히고 여백도 맞았다.
# → 종류마다 깨끗한 윤곽(물건 하나 · 투명 배경)을 모아 두고, 그 윤곽선만 ControlNet 으로 넘겨 목표 풍으로 새로 그린다. 색 · 질감은 넘어가지 않는다.
# 길잡이의 출처 셋: ① FR 장비 아이콘 원본 ② Layer Lab GUI Pro-FantasyRPG 아이콘(PD 보유 에셋) ③ 이 스크립트가 직접 그리는 단순 도형(원본이 없는 종류: 지팡이 · 화살통 · 뿔피리 · 오브 · 토템 · 투척 · 활)
# 합격한 생성물도 다시 길잡이로 쓸 수 있다(add_from).
# python icon_shapes.py → moba_anfg/shapes/<종류>__<출처>.png (1024 · 어두운 바탕 · 여백 통일) + shapes_sheet.png
import glob, io, math, os, sys
from PIL import Image, ImageDraw
HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(HERE, "moba_anfg", "shapes")
FR = r"C:\BurningTimes\FirstRising\Assets\GameAssets\UI\EquipmentItemIcon"
LL = r"C:\Users\sw\Downloads\Layer Lab\GUI Pro-FantasyRPG\ResourcesData\Sprites\Component"
LL_ITEM = os.path.join(LL, "Icon_ItemIcons", "NoShadow", "Original")
SIZE, FIT, BG = 1024, 0.70, (16, 16, 20)
# a7 검토(에이전트가 직접 봄) 반영:
# · Layer Lab 길잡이는 뭉툭한 비례 · 굵은 외곽선이 결과에 남아 "캐주얼"이 샌다 → 투구 · 책 · 사슬 목걸이만 남기고 뺀다.
# · 벨트는 fr1351(정면 띠 + 버클)만 허리띠로 읽혔다. 단검은 화면 끝에서 잘렸다 → 가는 무기는 더 작게 놓는다.
# · 직접 그린 도형은 밋밋했다(활 · 뿔 · 오브 · 화살통 · 토템) → 장식 선을 더해 다시 그렸다(draw*2). 도끼 · 반지 · 방패 도형을 새로 그렸다.
# 종류 → FR 원본 번호
FR_IDS = {
# a8 검토: fr1015(망치)는 가시 철퇴로, fr1043 · fr1071(단검)은 너무 작거나 삼지창으로, fr1029(활)는 0장 → 뺐다(직접 그린 도형으로 대신한다)
"blunt": [1057], # a12: fr1435(검)는 0/3(흰 납작면 · 잘림) → 직접 그린 검으로 대신
"necklace": [1099, 1113, 1127, 1141], "gloves": [1169, 1183, 1197, 1211], "armor": [1239, 1253, 1267, 1281],
"belt": [1351], "boots": [1379, 1393, 1407, 1421],
}
# 종류 → Layer Lab 아이템 아이콘 이름(…icon_<이름>.png)
LL_NAMES = {
# a8 검토: Layer Lab 투구 · 목걸이 · book_0 은 0장(캐주얼 윤곽 · 책이 판으로 나옴) → book_1 만 남긴다
"spellbook": ["book_1"],
}
# 가는 무기는 대각선으로 놓여 화면 끝에 닿기 쉽다 — 종류별로 더 작게 놓는다(기본 0.70)
FIT_BY_KIND = {"dagger": 0.74, "sword": 0.76, "staff": 0.74, "bow": 0.72, "throwing": 0.68, "blunt": 0.72, "hammer": 0.72, "axe": 0.74}
FG, LINE = (150, 150, 158), (70, 70, 78) # 도형의 면 · 안쪽 선(선화 추출이 테두리와 안쪽 선을 잡는다)
def place(im, fit=FIT):
"""투명 그림을 물체 테두리로 잘라 1024 어두운 바탕 가운데에 같은 여백으로 놓는다"""
im = im.convert("RGBA")
box = im.getchannel("A").getbbox()
if box:
im = im.crop(box)
scale = SIZE * fit / float(max(im.size))
im = im.resize((max(1, int(im.width * scale)), max(1, int(im.height * scale))), Image.LANCZOS)
rgb = Image.new("RGB", (SIZE, SIZE), BG)
rgb.paste(im, ((SIZE - im.width) // 2, (SIZE - im.height) // 2), im)
return rgb
def canvas():
im = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
return im, ImageDraw.Draw(im)
def draw_staff(kind):
"""지팡이: 긴 자루 + 머리 장식(0 = 마름모 수정 · 1 = 둥근 보석을 감싼 갈고리)"""
im, d = canvas()
d.rounded_rectangle([490, 250, 534, 1000], 14, fill=FG, outline=LINE, width=6)
for y in (420, 760, 940):
d.rectangle([482, y, 542, y + 22], fill=FG, outline=LINE, width=5)
if kind == 0:
d.polygon([(512, 20), (600, 150), (512, 280), (424, 150)], fill=FG, outline=LINE)
d.line([(512, 20), (512, 280)], fill=LINE, width=6); d.line([(424, 150), (600, 150)], fill=LINE, width=6)
d.polygon([(430, 250), (512, 300), (594, 250), (560, 330), (464, 330)], fill=FG, outline=LINE)
else:
d.ellipse([432, 60, 592, 220], fill=FG, outline=LINE, width=8)
d.arc([380, 10, 644, 300], 110, 250, fill=FG, width=34); d.arc([380, 10, 644, 300], 290, 70, fill=FG, width=34)
d.polygon([(450, 250), (574, 250), (540, 320), (484, 320)], fill=FG, outline=LINE)
return im.rotate(-35, resample=Image.BICUBIC, expand=True)
def draw_quiver():
"""화살통: 원통 + 위로 나온 화살 깃 + 어깨끈"""
im, d = canvas()
for k, x in enumerate((400, 470, 540, 610)):
top = 120 + (k % 2) * 50
d.rectangle([x - 7, top, x + 7, 420], fill=FG, outline=LINE, width=3)
d.polygon([(x - 34, top + 10), (x, top - 40), (x + 34, top + 10), (x + 34, top + 130), (x, top + 90), (x - 34, top + 130)], fill=FG, outline=LINE)
d.rounded_rectangle([340, 380, 680, 980], 60, fill=FG, outline=LINE, width=8)
d.rectangle([330, 380, 690, 450], fill=FG, outline=LINE, width=8)
d.rectangle([340, 820, 680, 870], fill=FG, outline=LINE, width=6)
d.arc([180, 300, 560, 900], 100, 260, fill=FG, width=30)
return im.rotate(-22, resample=Image.BICUBIC, expand=True)
def draw_horn():
"""뿔피리: 굽으면서 가늘어지는 뿔 + 테 둘"""
im, d = canvas()
cx, cy, R = 380, 300, 520
outer, inner = [], []
for k in range(0, 41):
t = k / 40.0
a = math.radians(5 + 100 * t)
w = 150 * (1 - t) + 14 # 입구 쪽이 굵고 끝이 가늘다
outer.append((cx + (R + w / 2) * math.cos(a), cy + (R + w / 2) * math.sin(a)))
inner.append((cx + (R - w / 2) * math.cos(a), cy + (R - w / 2) * math.sin(a)))
d.polygon(outer + inner[::-1], fill=FG, outline=LINE)
for t in (0.06, 0.45):
a = math.radians(5 + 100 * t); w = 150 * (1 - t) + 40
d.line([(cx + (R + w / 2) * math.cos(a), cy + (R + w / 2) * math.sin(a)), (cx + (R - w / 2) * math.cos(a), cy + (R - w / 2) * math.sin(a))], fill=LINE, width=26)
return im
def draw_orb():
"""오브: 구슬 + 받침"""
im, d = canvas()
d.polygon([(400, 640), (624, 640), (580, 800), (444, 800)], fill=FG, outline=LINE)
d.rounded_rectangle([340, 800, 684, 880], 24, fill=FG, outline=LINE, width=8)
d.arc([300, 380, 724, 760], 20, 160, fill=FG, width=40)
d.ellipse([292, 140, 732, 580], fill=FG, outline=LINE, width=10)
d.arc([350, 190, 600, 440], 180, 270, fill=LINE, width=8)
return im
def draw_totem():
"""토템: 층층이 쌓은 나무 기둥 + 옆 날개 + 받침"""
im, d = canvas()
d.polygon([(250, 300), (400, 250), (400, 380)], fill=FG, outline=LINE); d.polygon([(774, 300), (624, 250), (624, 380)], fill=FG, outline=LINE)
for k, y in enumerate((100, 330, 560)):
d.rounded_rectangle([390, y, 634, y + 220], 26, fill=FG, outline=LINE, width=8)
d.ellipse([440, y + 60, 500, y + 120], outline=LINE, width=8); d.ellipse([524, y + 60, 584, y + 120], outline=LINE, width=8)
d.rectangle([470, y + 150, 554, y + 180], outline=LINE, width=8)
d.rounded_rectangle([340, 780, 684, 880], 20, fill=FG, outline=LINE, width=8)
return im
def draw_throwing(kind):
"""투척: 0 = 쿠나이(잎 모양 날 + 자루 + 고리) · 1 = 네 날 표창"""
im, d = canvas()
if kind == 0:
d.polygon([(512, 60), (640, 420), (512, 560), (384, 420)], fill=FG, outline=LINE)
d.line([(512, 60), (512, 560)], fill=LINE, width=6)
d.rectangle([486, 560, 538, 800], fill=FG, outline=LINE, width=6)
d.ellipse([442, 790, 582, 930], outline=FG, width=34)
return im.rotate(-40, resample=Image.BICUBIC, expand=True)
for a in (0, 90, 180, 270):
r = math.radians(a)
tip = (512 + 420 * math.cos(r), 512 + 420 * math.sin(r))
l = (512 + 120 * math.cos(r + 1.0), 512 + 120 * math.sin(r + 1.0)); rr = (512 + 120 * math.cos(r - 1.0), 512 + 120 * math.sin(r - 1.0))
d.polygon([tip, l, rr], fill=FG, outline=LINE)
d.ellipse([412, 412, 612, 612], fill=FG, outline=LINE, width=10); d.ellipse([472, 472, 552, 552], fill=(0, 0, 0, 0), outline=LINE, width=8)
return im.rotate(20, resample=Image.BICUBIC, expand=True)
def draw_bow():
"""활: 굽은 몸체 + 곧은 시위 + 가운데 손잡이"""
im, d = canvas()
d.arc([200, 80, 900, 944], 100, 260, fill=FG, width=46)
top = (550 + 350 * math.cos(math.radians(260)), 512 + 432 * math.sin(math.radians(260)))
bot = (550 + 350 * math.cos(math.radians(100)), 512 + 432 * math.sin(math.radians(100)))
d.line([top, bot], fill=FG, width=8)
d.rounded_rectangle([170, 440, 250, 584], 20, fill=FG, outline=LINE, width=8)
return im.rotate(-30, resample=Image.BICUBIC, expand=True)
def arc_points(cx, cy, rx, ry, a0, a1, n=40):
"""타원 호 위의 점들(도형의 굽은 변을 만들 때 쓴다)"""
return [(cx + rx * math.cos(math.radians(a0 + (a1 - a0) * k / float(n))), cy + ry * math.sin(math.radians(a0 + (a1 - a0) * k / float(n)))) for k in range(n + 1)]
def draw_axe():
"""도끼: 자루가 날을 관통 + 속이 찬 부채꼴 날(바깥이 넓은 호) + 반대쪽 작은 뿔 + 자루 끝 마개.
(a9 검토: 처음 도형은 날 안쪽이 파여 낫으로 읽혔고 날과 자루가 한 점으로만 닿았다 → 날을 자루에 넓게 붙였다.)"""
im, d = canvas()
blade = [(520, 150)] + arc_points(520, 320, 330, 290, -62, 62) + [(520, 490)]
d.polygon(blade, fill=FG, outline=LINE)
d.line(arc_points(520, 320, 270, 235, -55, 55), fill=LINE, width=7) # 날 안쪽의 연마선
d.polygon([(500, 230), (360, 260), (330, 320), (360, 380), (500, 410)], fill=FG, outline=LINE) # 반대쪽 뿔
d.rounded_rectangle([482, 90, 542, 990], 16, fill=FG, outline=LINE, width=6)
d.rounded_rectangle([466, 200, 558, 440], 14, outline=LINE, width=8) # 날을 무는 쇠테
d.polygon([(482, 90), (512, 30), (542, 90)], fill=FG, outline=LINE)
for y in (640, 720, 800):
d.line([(482, y), (542, y)], fill=LINE, width=6)
d.rounded_rectangle([470, 930, 554, 1000], 14, fill=FG, outline=LINE, width=6)
return im.rotate(-35, resample=Image.BICUBIC, expand=True)
def draw_bow2():
"""활(다시): 굵은 리커브 몸체(끝이 바깥으로 말림) + 손잡이 + 끝 장식 + 시위"""
im, d = canvas()
outer = arc_points(620, 512, 400, 430, 115, 245)
inner = arc_points(650, 512, 370, 400, 245, 115)
d.polygon(outer + inner, fill=FG, outline=LINE)
d.line(arc_points(635, 512, 385, 415, 120, 240), fill=LINE, width=5)
top, bot = outer[-1], outer[0]
d.polygon([top, (top[0] + 90, top[1] - 40), (top[0] + 60, top[1] + 30)], fill=FG, outline=LINE)
d.polygon([bot, (bot[0] + 90, bot[1] + 40), (bot[0] + 60, bot[1] - 30)], fill=FG, outline=LINE)
d.line([(top[0] + 70, top[1] - 10), (bot[0] + 70, bot[1] + 10)], fill=FG, width=9)
d.rounded_rectangle([208, 420, 290, 604], 22, fill=FG, outline=LINE, width=8)
for y in (450, 512, 574):
d.line([(208, y), (290, y)], fill=LINE, width=6)
return im.rotate(-25, resample=Image.BICUBIC, expand=True)
def draw_horn2():
"""뿔피리(다시): 넓은 나팔 입구 + 테 둘 + 끈"""
im, d = canvas()
cx, cy, R = 300, 250, 520
outer, inner = [], []
for k in range(0, 41):
t = k / 40.0
a = math.radians(0 + 95 * t)
w = 260 * (1 - t) ** 1.3 + 26
outer.append((cx + (R + w / 2) * math.cos(a), cy + (R + w / 2) * math.sin(a)))
inner.append((cx + (R - w / 2) * math.cos(a), cy + (R - w / 2) * math.sin(a)))
d.polygon(outer + inner[::-1], fill=FG, outline=LINE)
d.ellipse([cx + R - 150, cy - 46, cx + R + 150, cy + 46], fill=FG, outline=LINE, width=9) # 나팔 입구
for t in (0.14, 0.52, 0.8):
a = math.radians(95 * t); w = 260 * (1 - t) ** 1.3 + 50
d.line([(cx + (R + w / 2) * math.cos(a), cy + (R + w / 2) * math.sin(a)), (cx + (R - w / 2) * math.cos(a), cy + (R - w / 2) * math.sin(a))], fill=LINE, width=30)
d.arc([330, 330, 760, 900], 190, 300, fill=FG, width=12)
return im
def draw_orb2():
"""오브(다시): 구슬 + 비스듬히 두른 금속 테 + 아래 작은 받침 발톱(받침대 기둥은 뺐다 — 트로피로 읽혔다)"""
im, d = canvas()
d.ellipse([232, 190, 792, 750], fill=FG, outline=LINE, width=12)
d.arc([300, 250, 560, 510], 180, 280, fill=LINE, width=9)
d.arc([170, 380, 854, 620], 0, 180, fill=LINE, width=34)
d.arc([170, 380, 854, 620], 0, 180, fill=FG, width=20)
for x in (360, 512, 664):
d.polygon([(x - 46, 730), (x + 46, 730), (x, 860)], fill=FG, outline=LINE)
return im
def draw_quiver2():
"""화살통(다시): 세운 원통 + 위로 나온 화살 셋(깃이 또렷) + 띠 + 어깨끈"""
im, d = canvas()
for k, x in enumerate((430, 512, 594)):
top = 90 + (k % 2) * 60
d.rectangle([x - 8, top + 60, x + 8, 430], fill=FG, outline=LINE, width=3)
d.polygon([(x - 44, top + 40), (x - 8, top), (x - 8, top + 190), (x - 44, top + 230)], fill=FG, outline=LINE)
d.polygon([(x + 44, top + 40), (x + 8, top), (x + 8, top + 190), (x + 44, top + 230)], fill=FG, outline=LINE)
d.polygon([(360, 400), (664, 400), (630, 960), (394, 960)], fill=FG, outline=LINE)
d.ellipse([350, 370, 674, 440], fill=FG, outline=LINE, width=8)
d.line([(372, 560), (652, 560)], fill=LINE, width=22); d.line([(386, 820), (638, 820)], fill=LINE, width=22)
d.ellipse([462, 640, 562, 740], outline=LINE, width=8)
d.arc([560, 380, 860, 940], 270, 90, fill=FG, width=26)
return im.rotate(-15, resample=Image.BICUBIC, expand=True)
def draw_ring():
"""반지: 얇은 고리(비스듬히 본 타원) + 위쪽 보석과 물림쇠"""
im, d = canvas()
d.ellipse([222, 330, 802, 910], outline=FG, width=64)
d.ellipse([222, 330, 802, 910], outline=LINE, width=6); d.ellipse([286, 394, 738, 846], outline=LINE, width=6)
d.polygon([(400, 330), (624, 330), (580, 420), (444, 420)], fill=FG, outline=LINE)
d.polygon([(512, 110), (640, 240), (580, 350), (444, 350), (384, 240)], fill=FG, outline=LINE)
d.line([(384, 240), (640, 240)], fill=LINE, width=6); d.line([(512, 110), (444, 350)], fill=LINE, width=5); d.line([(512, 110), (580, 350)], fill=LINE, width=5)
return im.rotate(-18, resample=Image.BICUBIC, expand=True)
def draw_totem2():
"""토템(다시): 세 단으로 쌓은 기둥의 외곽 + 단마다 띠 + 꼭대기 뿔 + 받침(얼굴 구멍은 뺐다 — 만화 얼굴로 읽혔다)"""
im, d = canvas()
d.polygon([(420, 110), (360, 20), (470, 80)], fill=FG, outline=LINE); d.polygon([(604, 110), (664, 20), (554, 80)], fill=FG, outline=LINE)
for k, (y, w) in enumerate(((90, 250), (330, 290), (590, 250))):
d.rounded_rectangle([512 - w // 2, y, 512 + w // 2, y + 230], 30, fill=FG, outline=LINE, width=8)
d.line([(512 - w // 2 + 10, y + 180), (512 + w // 2 - 10, y + 180)], fill=LINE, width=14)
d.polygon([(512, y + 40), (560, y + 100), (512, y + 160), (464, y + 100)], outline=LINE)
d.polygon([(367, 400), (240, 360), (367, 500)], fill=FG, outline=LINE); d.polygon([(657, 400), (784, 360), (657, 500)], fill=FG, outline=LINE)
d.rounded_rectangle([350, 820, 674, 910], 20, fill=FG, outline=LINE, width=8)
return im
def draw_shield():
"""방패: 연 모양 몸체 + 안쪽 테 + 가운데 돌기 + 세로 · 가로 띠"""
im, d = canvas()
body = [(512, 80), (800, 170), (780, 560), (512, 950), (244, 560), (224, 170)]
d.polygon(body, fill=FG, outline=LINE)
inner = [(512, 150), (735, 220), (718, 540), (512, 850), (306, 540), (289, 220)]
d.line(inner + [inner[0]], fill=LINE, width=10)
d.line([(512, 150), (512, 850)], fill=LINE, width=8); d.line([(295, 400), (729, 400)], fill=LINE, width=8)
d.ellipse([432, 320, 592, 480], fill=FG, outline=LINE, width=10)
return im.rotate(-12, resample=Image.BICUBIC, expand=True)
def draw_dagger():
"""단검: 짧고 넓은 날 + 가로 날밑 + 짧은 손잡이 + 자루 끝 구슬"""
im, d = canvas()
d.polygon([(512, 60), (590, 220), (575, 600), (449, 600), (434, 220)], fill=FG, outline=LINE)
d.line([(512, 60), (512, 600)], fill=LINE, width=6)
d.polygon([(330, 590), (694, 590), (720, 630), (694, 670), (330, 670), (304, 630)], fill=FG, outline=LINE)
d.rounded_rectangle([474, 670, 550, 880], 14, fill=FG, outline=LINE, width=6)
for y in (720, 770, 820):
d.line([(474, y), (550, y)], fill=LINE, width=6)
d.ellipse([456, 866, 568, 978], fill=FG, outline=LINE, width=8)
return im.rotate(-40, resample=Image.BICUBIC, expand=True)
def draw_hammer():
"""망치: 직사각 덩어리 머리(양쪽 테) + 곧은 자루 + 자루 끝 마개 — 가시가 없어 철퇴와 구분된다"""
im, d = canvas()
d.rounded_rectangle([484, 300, 540, 990], 16, fill=FG, outline=LINE, width=6)
d.rounded_rectangle([250, 80, 774, 360], 30, fill=FG, outline=LINE, width=10)
d.rectangle([250, 80, 330, 360], outline=LINE, width=10); d.rectangle([694, 80, 774, 360], outline=LINE, width=10)
d.ellipse([452, 160, 572, 280], outline=LINE, width=9)
d.rounded_rectangle([462, 350, 562, 430], 12, fill=FG, outline=LINE, width=7)
for y in (620, 700, 780):
d.line([(484, y), (540, y)], fill=LINE, width=6)
d.rounded_rectangle([470, 930, 554, 1000], 14, fill=FG, outline=LINE, width=6)
return im.rotate(-35, resample=Image.BICUBIC, expand=True)
def draw_totem3():
"""토템(셋째): 통나무 기둥 하나에 새긴 얼굴 둘(눈 · 입 윤곽) + 머리 깃 + 옆 날개 + 받침 — 블록 쌓기로 읽히지 않게 기둥을 한 덩어리로 그린다"""
im, d = canvas()
d.polygon([(380, 330), (220, 250), (250, 400), (380, 430)], fill=FG, outline=LINE)
d.polygon([(644, 330), (804, 250), (774, 400), (644, 430)], fill=FG, outline=LINE)
for x in (440, 512, 584):
d.polygon([(x - 30, 130), (x, 20 if x == 512 else 50), (x + 30, 130)], fill=FG, outline=LINE)
d.rounded_rectangle([380, 120, 644, 860], 40, fill=FG, outline=LINE, width=10)
for y in (170, 510):
d.polygon([(420, y + 40), (490, y + 60), (480, y + 110), (420, y + 100)], outline=LINE) # 왼눈
d.polygon([(604, y + 40), (534, y + 60), (544, y + 110), (604, y + 100)], outline=LINE) # 오른눈
d.polygon([(512, y + 100), (540, y + 190), (484, y + 190)], outline=LINE) # 코
d.rounded_rectangle([430, y + 220, 594, y + 280], 12, outline=LINE, width=8) # 입
for x in (470, 512, 554):
d.line([(x, y + 220), (x, y + 280)], fill=LINE, width=6)
d.line([(380, 490), (644, 490)], fill=LINE, width=16)
d.rounded_rectangle([330, 850, 694, 940], 22, fill=FG, outline=LINE, width=8)
return im
def draw_helmet(kind):
"""투구: 둥근 머리 + 얼굴 가리개(눈 틈) + 목 보호대. 0 = 꼭대기 볏 · 1 = 양옆 뿔"""
im, d = canvas()
if kind == 1:
# 뿔: 투구 옆면(밑동이 본체 안으로 겹친다)에서 솟아 위로 휘며 뾰족해진다
d.polygon([(330, 420), (280, 540), (150, 430), (110, 250), (150, 90), (200, 250), (260, 360)], fill=FG, outline=LINE)
d.polygon([(694, 420), (744, 540), (874, 430), (914, 250), (874, 90), (824, 250), (764, 360)], fill=FG, outline=LINE)
d.polygon(arc_points(512, 470, 270, 330, 180, 360) + [(782, 760), (680, 900), (344, 900), (242, 760)], fill=FG, outline=LINE)
d.line(arc_points(512, 470, 270, 330, 180, 360), fill=LINE, width=10)
d.polygon([(300, 470), (724, 470), (700, 560), (540, 560), (512, 620), (484, 560), (324, 560)], fill=(0, 0, 0, 0), outline=LINE) # 눈 틈
d.line([(512, 150), (512, 470)], fill=LINE, width=10)
d.line([(512, 620), (512, 900)], fill=LINE, width=8)
d.line([(242, 760), (782, 760)], fill=LINE, width=8)
if kind == 0:
d.polygon(arc_points(512, 300, 60, 260, 180, 360) + [(572, 300), (452, 300)], fill=FG, outline=LINE)
return im
def draw_sword2():
"""검(직접 그림): 넓은 날 + 가운데 홈(풀러) + 날개형 날밑 + 감은 손잡이 + 마름모 자루 끝.
(a12 검토: fr1435 윤곽은 너무 가늘어 흰 납작면 · 잘림이 났다 → 날을 넓히고 안쪽 선을 넣어 명암을 올릴 자리를 만든다.)"""
im, d = canvas()
d.polygon([(512, 30), (578, 170), (570, 640), (454, 640), (446, 170)], fill=FG, outline=LINE)
d.line([(512, 60), (512, 640)], fill=LINE, width=7)
d.line([(486, 190), (486, 640)], fill=LINE, width=4); d.line([(538, 190), (538, 640)], fill=LINE, width=4)
d.polygon([(290, 650), (350, 600), (454, 626), (570, 626), (674, 600), (734, 650), (700, 712), (600, 694), (424, 694), (324, 712)], fill=FG, outline=LINE)
d.ellipse([478, 630, 546, 698], outline=LINE, width=7)
d.rounded_rectangle([480, 694, 544, 880], 12, fill=FG, outline=LINE, width=6)
for y in (730, 770, 810, 850):
d.line([(480, y), (544, y - 18)], fill=LINE, width=5)
d.polygon([(512, 840), (576, 916), (512, 992), (448, 916)], fill=FG, outline=LINE) # 자루 끝(손잡이와 겹친다 — a13: 떨어져 보였다)
return im.rotate(-40, resample=Image.BICUBIC, expand=True)
def draw_dagger2():
"""단검(다시): 잎 모양의 짧은 날 + 홈 + 굽은 작은 날밑 + 손잡이 + 둥근 자루 끝.
(a12 검토: 처음 도형은 날밑이 날만큼 넓어 '뭉툭한 십자 덩어리'가 됐다 → 날을 길게, 날밑을 좁게.)"""
im, d = canvas()
d.polygon([(512, 90), (592, 300), (566, 640), (458, 640), (432, 300)], fill=FG, outline=LINE)
d.line([(512, 120), (512, 640)], fill=LINE, width=7)
d.line([(478, 320), (486, 640)], fill=LINE, width=4); d.line([(546, 320), (538, 640)], fill=LINE, width=4)
d.polygon([(370, 610), (420, 640), (604, 640), (654, 610), (650, 690), (590, 700), (434, 700), (374, 690)], fill=FG, outline=LINE)
d.rounded_rectangle([478, 700, 546, 870], 12, fill=FG, outline=LINE, width=6)
for y in (735, 775, 815):
d.line([(478, y), (546, y)], fill=LINE, width=5)
d.ellipse([458, 856, 566, 964], fill=FG, outline=LINE, width=8)
return im.rotate(-40, resample=Image.BICUBIC, expand=True)
def draw_axe3():
"""도끼(셋째): 자루에 넓게 붙은 속 찬 수염 도끼 머리(각진 다각형 + 안쪽 연마선) + 반대쪽 뿔 + 꼭대기 창끝.
(a12 검토: 부채꼴 호가 초승달 조각으로 떨어져 나왔다 → 호를 쓰지 않고 자루와 면으로 맞닿는 각진 머리로 그린다.)"""
im, d = canvas()
d.rounded_rectangle([480, 110, 544, 990], 16, fill=FG, outline=LINE, width=6)
d.polygon([(544, 170), (700, 110), (830, 210), (870, 350), (830, 500), (720, 600), (640, 470), (544, 450)], fill=FG, outline=LINE)
d.line([(690, 160), (790, 240), (822, 350), (790, 470), (716, 540)], fill=LINE, width=7)
d.polygon([(480, 240), (360, 300), (480, 380)], fill=FG, outline=LINE)
d.rounded_rectangle([462, 160, 562, 460], 14, outline=LINE, width=8)
d.polygon([(480, 110), (512, 30), (544, 110)], fill=FG, outline=LINE)
for y in (640, 720, 800):
d.line([(480, y), (544, y)], fill=LINE, width=6)
d.rounded_rectangle([468, 930, 556, 1000], 14, fill=FG, outline=LINE, width=6)
return im.rotate(-35, resample=Image.BICUBIC, expand=True)
def draw_axe4():
"""도끼(넷째): 속이 찬 반달 날 한 덩어리가 자루와 넓게 겹친다. 안쪽 윤곽은 날 끝 쪽의 연마선 한 줄뿐.
(a13 검토: 셋째 도형도 안쪽 윤곽이 고리로 읽혀 '속 빈 초승달'이 됐다 → 쇠테 · 겹선을 모두 없앴다.)"""
im, d = canvas()
d.rounded_rectangle([478, 110, 546, 990], 16, fill=FG, outline=LINE, width=6)
blade = [(500, 150)] + arc_points(500, 330, 360, 250, -80, 80, 30) + [(500, 510)]
d.polygon(blade, fill=FG, outline=LINE)
d.line(arc_points(500, 330, 315, 210, -62, 62, 30), fill=LINE, width=6)
d.polygon([(478, 250), (380, 330), (478, 410)], fill=FG, outline=LINE)
d.polygon([(478, 110), (512, 30), (546, 110)], fill=FG, outline=LINE)
for y in (660, 740, 820):
d.line([(478, y), (546, y)], fill=LINE, width=6)
d.rounded_rectangle([466, 930, 558, 1000], 14, fill=FG, outline=LINE, width=6)
return im.rotate(-35, resample=Image.BICUBIC, expand=True)
def draw_dagger3():
"""단검(셋째): 넓고 짧은 날(길이:폭 약 3:1) + 날 길이의 60% 에서 닫히는 가는 홈 + 작은 날밑 + 손잡이 + 자루 끝(손잡이와 겹침).
(a13 검토: 둘째 도형은 끝까지 그은 중심선이 날을 둘로 가르는 경계로 읽혀 두 갈래 날이 됐다.)"""
im, d = canvas()
d.polygon([(512, 110), (606, 330), (586, 650), (438, 650), (418, 330)], fill=FG, outline=LINE)
d.ellipse([498, 330, 526, 640], outline=LINE, width=5)
d.polygon([(360, 630), (438, 650), (586, 650), (664, 630), (650, 706), (374, 706)], fill=FG, outline=LINE)
d.rounded_rectangle([476, 700, 548, 880], 12, fill=FG, outline=LINE, width=6)
for y in (740, 785, 830):
d.line([(476, y), (548, y)], fill=LINE, width=5)
d.ellipse([454, 850, 570, 966], fill=FG, outline=LINE, width=8)
return im.rotate(-40, resample=Image.BICUBIC, expand=True)
def draw_bow3():
"""활(셋째): 가운데가 굵고 양 끝으로 가늘어지는 활대 + 바깥으로 젖힌 끝 고리 + 감은 손잡이 + 손잡이 위아래 마름모 장식 + 가는 시위 한 줄.
(a13 검토: 형태는 처음으로 온전했으나 굵기가 일정한 '매끈한 관'이라 밋밋했다. 시위가 굵으면 막대로 그려진다.)"""
im, d = canvas()
outer, inner = [], []
for k in range(0, 61):
t = k / 60.0
ang = math.radians(118 + 124 * t) # 활대의 중심선(왼쪽으로 볼록한 호)
w = 22 + 46 * (1 - abs(2 * t - 1)) ** 0.8 # 가운데 68 → 끝 22
cx, cy = 640 + 400 * math.cos(ang), 512 + 440 * math.sin(ang)
nx, ny = math.cos(ang), math.sin(ang)
outer.append((cx + nx * w / 2, cy + ny * w / 2)); inner.append((cx - nx * w / 2, cy - ny * w / 2))
d.polygon(outer + inner[::-1], fill=FG, outline=LINE)
top = (640 + 400 * math.cos(math.radians(242)), 512 + 440 * math.sin(math.radians(242)))
bot = (640 + 400 * math.cos(math.radians(118)), 512 + 440 * math.sin(math.radians(118)))
for (x, y), sgn in ((top, -1), (bot, 1)):
d.polygon([(x - 20, y), (x + 70, y + sgn * 70), (x + 96, y + sgn * 40), (x + 30, y - sgn * 24)], fill=FG, outline=LINE) # 젖힌 끝
d.line([(top[0] + 78, top[1] - 52), (bot[0] + 78, bot[1] + 52)], fill=FG, width=5) # 시위
d.rounded_rectangle([198, 430, 290, 594], 20, fill=FG, outline=LINE, width=7)
for y in (462, 496, 530, 564):
d.line([(198, y), (290, y - 14)], fill=LINE, width=5)
for y in (360, 664):
d.polygon([(250, y - 46), (292, y), (250, y + 46), (208, y)], fill=FG, outline=LINE)
return im.rotate(-25, resample=Image.BICUBIC, expand=True)
def draw_hammer2():
"""망치(다시): 직사각 머리 + 양 끝의 두꺼운 띠와 모서리 장식 + 가운데 문장 + 자루 목의 쇠테 + 감은 자루.
(a13 검토: 처음 도형은 '단순한 상자 머리'로 장식이 부족했다.)"""
im, d = canvas()
d.rounded_rectangle([482, 300, 542, 990], 16, fill=FG, outline=LINE, width=6)
d.rounded_rectangle([250, 90, 774, 360], 26, fill=FG, outline=LINE, width=10)
for x0, x1 in ((230, 330), (694, 794)):
d.rounded_rectangle([x0, 70, x1, 380], 18, fill=FG, outline=LINE, width=9)
d.line([(x0 + 50, 70), (x0 + 50, 380)], fill=LINE, width=5)
d.polygon([(512, 140), (590, 225), (512, 310), (434, 225)], outline=LINE)
d.line([(512, 140), (512, 310)], fill=LINE, width=5); d.line([(434, 225), (590, 225)], fill=LINE, width=5)
d.polygon([(482, 90), (512, 36), (542, 90)], fill=FG, outline=LINE)
d.rounded_rectangle([458, 350, 566, 440], 12, fill=FG, outline=LINE, width=7)
for y in (560, 610, 660, 710, 760, 810):
d.line([(482, y), (542, y - 18)], fill=LINE, width=5)
d.rounded_rectangle([466, 920, 558, 1000], 14, fill=FG, outline=LINE, width=6)
return im.rotate(-35, resample=Image.BICUBIC, expand=True)
DRAWN = {
"staff": [("draw0", lambda: draw_staff(0)), ("draw1", lambda: draw_staff(1))],
"quiver": [("draw2", draw_quiver2)], "horn": [("draw2", draw_horn2)], "orb": [("draw2", draw_orb2)], "totem": [("draw3", draw_totem3)],
"throwing": [("draw0", lambda: draw_throwing(0)), ("draw1", lambda: draw_throwing(1))], "bow": [("draw3", draw_bow3)],
"axe": [("draw4", draw_axe4)], "ring": [("draw0", draw_ring)], "shield": [("draw0", draw_shield)],
"dagger": [("draw3", draw_dagger3)], "hammer": [("draw2", draw_hammer2)], "helmet": [("draw0", lambda: draw_helmet(0)), ("draw1", lambda: draw_helmet(1))],
"sword": [("draw2", draw_sword2)],
}
def add_from(kind, path, label):
"""합격한 생성물(투명 PNG)을 길잡이 모음에 더한다 — 다른 색 · 장식으로 다시 그릴 때의 형태가 된다. active.txt 에도 올린다."""
os.makedirs(OUT, exist_ok=True)
name = "%s__%s.png" % (kind, label)
place(Image.open(path), FIT_BY_KIND.get(kind, FIT)).save(os.path.join(OUT, name))
act = os.path.join(OUT, "active.txt")
have = io.open(act, encoding="utf-8").read().split() if os.path.exists(act) else []
if name not in have:
io.open(act, "a", encoding="utf-8").write(name + "\n")
def main():
os.makedirs(OUT, exist_ok=True)
active = [] # 이번 정의로 쓰는 길잡이(예전 정의로 만든 파일은 지우지 않고 폴더에 남긴다 — 생성 스크립트는 active.txt 만 본다)
for kind, ids in FR_IDS.items():
for gid in ids:
name = "%s__fr%d.png" % (kind, gid)
place(Image.open(os.path.join(FR, "%d.png" % gid)), FIT_BY_KIND.get(kind, FIT)).save(os.path.join(OUT, name)); active.append(name)
for kind, names in LL_NAMES.items():
for nm in names:
hits = glob.glob(os.path.join(LL_ITEM, "*icon_%s.png" % nm))
if not hits:
print("Layer Lab 에 없음:", nm); continue
name = "%s__ll_%s.png" % (kind, nm)
place(Image.open(hits[0]), FIT_BY_KIND.get(kind, FIT)).save(os.path.join(OUT, name)); active.append(name)
for kind, items in DRAWN.items():
for label, fn in items:
name = "%s__%s.png" % (kind, label)
place(fn(), FIT_BY_KIND.get(kind, FIT)).save(os.path.join(OUT, name)); active.append(name)
# add_from 으로 더한 합격본 길잡이(gen*)는 계속 쓴다
active += [os.path.basename(f) for f in glob.glob(os.path.join(OUT, "*__gen*.png"))]
active = sorted(set(active))
io.open(os.path.join(OUT, "active.txt"), "w", encoding="utf-8").write("\n".join(active) + "\n")
cell, pad, cols = 160, 4, 10
rows = (len(active) + cols - 1) // cols
sheet = Image.new("RGB", (cols * (cell + pad) + pad, rows * (cell + pad) + pad), (40, 40, 44))
d = ImageDraw.Draw(sheet)
for i, name in enumerate(active):
x, y = pad + (i % cols) * (cell + pad), pad + (i // cols) * (cell + pad)
sheet.paste(Image.open(os.path.join(OUT, name)).resize((cell, cell), Image.LANCZOS), (x, y))
d.text((x + 3, y + 2), name[:-4], fill=(240, 220, 160))
sheet.save(os.path.join(OUT, "shapes_sheet.png"))
print("shapes %d (%d 종류) -> %s" % (len(active), len(set(n.split("__")[0] for n in active)), OUT))
if __name__ == "__main__":
# python icon_shapes.py → 길잡이 모음을 다시 만든다
# python icon_shapes.py add <종류> <투명 PNG 경로> <이름> → 그림 한 장의 윤곽을 길잡이로 등록(이름은 gen 으로 시작해야 다시 만들 때 남는다)
if len(sys.argv) > 4 and sys.argv[1] == "add":
add_from(sys.argv[2], sys.argv[3], sys.argv[4]); print("등록", sys.argv[2], sys.argv[4])
else:
main()

Binary file not shown.

After

Width:  |  Height:  |  Size: 1003 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1022 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 598 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 922 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 779 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 577 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 792 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 554 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

View File

@ -0,0 +1,10 @@
{
"a14_armor-fr1267_1": "a14_armor-fr1267_s07_1.png",
"a14_belt-fr1351_0": "a14_belt-fr1351_s07_0.png",
"a14_boots-fr1379_1": "a14_boots-fr1379_s07_1.png",
"a14_gloves-fr1169_1": "a14_gloves-fr1169_s07_1.png",
"a14_necklace-fr1099_0": "a14_necklace-fr1099_s07_0.png",
"a14_shield-draw0_0": "a14_shield-draw0_s07_0.png",
"a14_spellbook-llbook1_0": "a14_spellbook-llbook1_s07_0.png",
"a14_totem-draw3_1": "a14_totem-draw3_s07_1.png"
}

View File

@ -0,0 +1,15 @@
{
"a17_armor-fr1253_1": "a17_armor-fr1253_s07_1.png",
"a17_armor-fr1267_1": "a17_armor-fr1267_s07_1.png",
"a17_boots-fr1393_0": "a17_boots-fr1393_s07_0.png",
"a17_gloves-fr1183_0": "a17_gloves-fr1183_s07_0.png",
"a17_necklace-fr1127_0": "a17_necklace-fr1127_s07_0.png",
"a17_orb-genmoba1_0": "a17_orb-genmoba1_s07_0.png",
"a17_helmet-genmoba0_0": "a17_helmet-genmoba0_s07_0.png",
"a17_staff-draw1_1": "a17_staff-draw1_s07_1.png",
"a17_blunt-fr1057_1": "a17_blunt-fr1057_s07_1.png",
"a17_totem-draw3_1": "a17_totem-draw3_s07_1.png",
"a17_spellbook-llbook1_1": "a17_spellbook-llbook1_s07_1.png",
"a17_belt-fr1351_1": "a17_belt-fr1351_s07_1.png",
"a17_throwing-draw0_0": "a17_throwing-draw0_s07_0.png"
}

View File

@ -0,0 +1,7 @@
{
"a19_sword-genxl1_1": "a19_sword-genxl1_s07_1.png",
"a19_ring-genxl0_1": "a19_ring-genxl0_s07_1.png",
"a19_quiver-genxl1_0": "a19_quiver-genxl1_s07_0.png",
"a19_shield-genxl1_0": "a19_shield-genxl1_s07_0.png",
"a19_helmet-genxl1_0": "a19_helmet-genxl1_s07_0.png"
}

View File

@ -0,0 +1,6 @@
{
"a20_dagger-genxlb0_1": "a20_dagger-genxlb0_s07_1.png",
"a20_bow-genxlb0_1": "a20_bow-genxlb0_s07_1.png",
"a20_belt-genxlb0_1": "a20_belt-genxlb0_s07_1.png",
"a20_staff-genxlb0_2": "a20_staff-genxlb0_s07_2.png"
}

View File

@ -0,0 +1,4 @@
{
"a21_necklace-genxlc0_2": "a21_necklace-genxlc0_s07_2.png",
"a21_spellbook-genxlc0_0": "a21_spellbook-genxlc0_s07_0.png"
}

View File

@ -0,0 +1,10 @@
{
"a23_axe-genxld1_1": "a23_axe-genxld1_s07_1.png",
"a23_hammer-genxld1_2": "a23_hammer-genxld1_s07_2.png",
"a23_helmet-genxld2_1": "a23_helmet-genxld2_s07_1.png",
"a23_helmet-genxld3_2": "a23_helmet-genxld3_s07_2.png",
"a23_quiver-genxld2_0": "a23_quiver-genxld2_s07_0.png",
"a23_ring-genxld1_0": "a23_ring-genxld1_s07_0.png",
"a23_ring-genxld3_0": "a23_ring-genxld3_s07_0.png",
"a23_shield-genxld3_0": "a23_shield-genxld3_s07_0.png"
}

View File

@ -0,0 +1,15 @@
{
"a24_axe-genxld1_0": "a24_axe-genxld1_s07_0.png",
"a24_dagger-genxlb0_3": "a24_dagger-genxlb0_s07_3.png",
"a24_dagger-genxlb0_2": "a24_dagger-genxlb0_s07_2.png",
"a24_hammer-genxld0_3": "a24_hammer-genxld0_s07_3.png",
"a24_hammer-genxld1_3": "a24_hammer-genxld1_s07_3.png",
"a24_horn-draw2_0": "a24_horn-draw2_s07_0.png",
"a24_horn-draw2_2": "a24_horn-draw2_s07_2.png",
"a24_orb-genmoba1_1": "a24_orb-genmoba1_s07_1.png",
"a24_orb-genmoba1_0": "a24_orb-genmoba1_s07_0.png",
"a24_spellbook-genxlc0_0": "a24_spellbook-genxlc0_s07_0.png",
"a24_spellbook-genxlc0_3": "a24_spellbook-genxlc0_s07_3.png",
"a24_sword-genxl1_1": "a24_sword-genxl1_s07_1.png",
"a24_sword-genxl1_0": "a24_sword-genxl1_s07_0.png"
}

View File

@ -0,0 +1,26 @@
{
"a26_armor-fr1253_0": "a26_armor-fr1253_s07_0.png",
"a26_armor-fr1281_2": "a26_armor-fr1281_s07_2.png",
"a26_belt-fr1351_0": "a26_belt-fr1351_s07_0.png",
"a26_belt-genxlb0_2": "a26_belt-genxlb0_s07_2.png",
"a26_blunt-fr1057_0": "a26_blunt-fr1057_s07_0.png",
"a26_boots-fr1393_2": "a26_boots-fr1393_s07_2.png",
"a26_gloves-fr1183_0": "a26_gloves-fr1183_s07_0.png",
"a26_gloves-fr1197_1": "a26_gloves-fr1197_s07_1.png",
"a26_helmet-genmoba0_1": "a26_helmet-genmoba0_s07_1.png",
"a26_horn-draw2_0": "a26_horn-draw2_s07_0.png",
"a26_necklace-fr1099_0": "a26_necklace-fr1099_s07_0.png",
"a26_necklace-fr1127_1": "a26_necklace-fr1127_s07_1.png",
"a26_shield-draw0_0": "a26_shield-draw0_s07_0.png",
"a26_staff-genxlb0_1": "a26_staff-genxlb0_s07_1.png",
"a26_throwing-draw0_1": "a26_throwing-draw0_s07_1.png",
"a26_totem-draw3_0": "a26_totem-draw3_s07_0.png",
"a26_dagger-genxlb0_2": "a26_dagger-genxlb0_s07_2.png",
"a26_bow-genxlb0_1": "a26_bow-genxlb0_s07_1.png",
"a26_spellbook-genxlc0_0": "a26_spellbook-genxlc0_s07_0.png",
"a26_axe-genxld1_2": "a26_axe-genxld1_s07_2.png",
"a26_hammer-genxld0_1": "a26_hammer-genxld0_s07_1.png",
"a26_hammer-genxld1_1": "a26_hammer-genxld1_s07_1.png",
"a26_ring-genxld2_2": "a26_ring-genxld2_s07_2.png",
"a26_ring-genxld3_1": "a26_ring-genxld3_s07_1.png"
}

View File

@ -0,0 +1,34 @@
{
"a27_sword-genxl1_1": "a27_sword-genxl1_s07_1.png",
"a27_dagger-genxlb0_1": "a27_dagger-genxlb0_s07_1.png",
"a27_axe-genxld1_0": "a27_axe-genxld1_s07_0.png",
"a26_blunt-fr1057_0": "a26_blunt-fr1057_s07_0.png",
"a27_hammer-genxld1_1": "a27_hammer-genxld1_s07_1.png",
"a27_hammer-genxld0_1": "a27_hammer-genxld0_s07_1.png",
"a26_bow-genxlb0_1": "a26_bow-genxlb0_s07_1.png",
"a26_staff-genxlb0_1": "a26_staff-genxlb0_s07_1.png",
"a27_staff-draw1_0": "a27_staff-draw1_s07_0.png",
"a26_throwing-draw0_1": "a26_throwing-draw0_s07_1.png",
"a27_orb-genmoba1_0": "a27_orb-genmoba1_s07_0.png",
"a26_spellbook-genxlc0_0": "a26_spellbook-genxlc0_s07_0.png",
"a27_totem-draw3_1": "a27_totem-draw3_s07_1.png",
"a26_horn-draw2_0": "a26_horn-draw2_s07_0.png",
"a27_quiver-genxld2_0": "a27_quiver-genxld2_s07_0.png",
"a27_shield-draw0_1": "a27_shield-draw0_s07_1.png",
"a27_shield-genxld3_1": "a27_shield-genxld3_s07_1.png",
"a27_armor-fr1253_1": "a27_armor-fr1253_s07_1.png",
"a27_armor-fr1267_1": "a27_armor-fr1267_s07_1.png",
"a26_armor-fr1281_2": "a26_armor-fr1281_s07_2.png",
"a27_helmet-genxld3_0": "a27_helmet-genxld3_s07_0.png",
"a26_gloves-fr1197_1": "a26_gloves-fr1197_s07_1.png",
"a27_gloves-fr1197_1": "a27_gloves-fr1197_s07_1.png",
"a27_boots-fr1379_1": "a27_boots-fr1379_s07_1.png",
"a27_boots-fr1407_0": "a27_boots-fr1407_s07_0.png",
"a26_belt-genxlb0_2": "a26_belt-genxlb0_s07_2.png",
"a27_belt-fr1351_0": "a27_belt-fr1351_s07_0.png",
"a27_ring-genxld2_0": "a27_ring-genxld2_s07_0.png",
"a27_ring-genxld3_0": "a27_ring-genxld3_s07_0.png",
"a26_ring-genxld3_1": "a26_ring-genxld3_s07_1.png",
"a27_necklace-fr1113_0": "a27_necklace-fr1113_s07_0.png",
"a27_necklace-genxlc0_1": "a27_necklace-genxlc0_s07_1.png"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 731 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 706 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 750 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 665 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 698 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 619 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 627 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 684 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 524 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 KiB

View File

@ -0,0 +1,130 @@
[
{
"icon": "sword_01.png",
"source": "a27_sword-genxl1_s07_1.png"
},
{
"icon": "dagger_01.png",
"source": "a27_dagger-genxlb0_s07_1.png"
},
{
"icon": "axe_01.png",
"source": "a27_axe-genxld1_s07_0.png"
},
{
"icon": "blunt_01.png",
"source": "a26_blunt-fr1057_s07_0.png"
},
{
"icon": "hammer_01.png",
"source": "a27_hammer-genxld0_s07_1.png"
},
{
"icon": "hammer_02.png",
"source": "a27_hammer-genxld1_s07_1.png"
},
{
"icon": "bow_01.png",
"source": "a26_bow-genxlb0_s07_1.png"
},
{
"icon": "staff_01.png",
"source": "a26_staff-genxlb0_s07_1.png"
},
{
"icon": "staff_02.png",
"source": "a27_staff-draw1_s07_0.png"
},
{
"icon": "armor_01.png",
"source": "a26_armor-fr1281_s07_2.png"
},
{
"icon": "armor_02.png",
"source": "a27_armor-fr1253_s07_1.png"
},
{
"icon": "armor_03.png",
"source": "a27_armor-fr1267_s07_1.png"
},
{
"icon": "helmet_01.png",
"source": "a27_helmet-genxld3_s07_0.png"
},
{
"icon": "gloves_01.png",
"source": "a26_gloves-fr1197_s07_1.png"
},
{
"icon": "gloves_02.png",
"source": "a27_gloves-fr1197_s07_1.png"
},
{
"icon": "belt_01.png",
"source": "a26_belt-genxlb0_s07_2.png"
},
{
"icon": "belt_02.png",
"source": "a27_belt-fr1351_s07_0.png"
},
{
"icon": "boots_01.png",
"source": "a27_boots-fr1379_s07_1.png"
},
{
"icon": "boots_02.png",
"source": "a27_boots-fr1407_s07_0.png"
},
{
"icon": "ring_01.png",
"source": "a26_ring-genxld3_s07_1.png"
},
{
"icon": "ring_02.png",
"source": "a27_ring-genxld2_s07_0.png"
},
{
"icon": "ring_03.png",
"source": "a27_ring-genxld3_s07_0.png"
},
{
"icon": "necklace_01.png",
"source": "a27_necklace-fr1113_s07_0.png"
},
{
"icon": "necklace_02.png",
"source": "a27_necklace-genxlc0_s07_1.png"
},
{
"icon": "shield_01.png",
"source": "a27_shield-draw0_s07_1.png"
},
{
"icon": "shield_02.png",
"source": "a27_shield-genxld3_s07_1.png"
},
{
"icon": "quiver_01.png",
"source": "a27_quiver-genxld2_s07_0.png"
},
{
"icon": "spellbook_01.png",
"source": "a26_spellbook-genxlc0_s07_0.png"
},
{
"icon": "throwing_01.png",
"source": "a26_throwing-draw0_s07_1.png"
},
{
"icon": "orb_01.png",
"source": "a27_orb-genmoba1_s07_0.png"
},
{
"icon": "totem_01.png",
"source": "a27_totem-draw3_s07_1.png"
},
{
"icon": "horn_01.png",
"source": "a26_horn-draw2_s07_0.png"
}
]

Binary file not shown.

After

Width:  |  Height:  |  Size: 431 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 434 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 642 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 585 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 724 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 541 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 534 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 717 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 KiB

View File

@ -0,0 +1,27 @@
{
"a14_armor-fr1267_1": "a14_armor-fr1267_s07_1.png",
"a14_belt-fr1351_0": "a14_belt-fr1351_s07_0.png",
"a14_boots-fr1379_1": "a14_boots-fr1379_s07_1.png",
"a14_gloves-fr1169_1": "a14_gloves-fr1169_s07_1.png",
"a14_necklace-fr1099_0": "a14_necklace-fr1099_s07_0.png",
"a14_shield-draw0_0": "a14_shield-draw0_s07_0.png",
"a14_totem-draw3_1": "a14_totem-draw3_s07_1.png",
"a17_armor-fr1253_1": "a17_armor-fr1253_s07_1.png",
"a17_armor-fr1267_1": "a17_armor-fr1267_s07_1.png",
"a17_boots-fr1393_0": "a17_boots-fr1393_s07_0.png",
"a17_gloves-fr1183_0": "a17_gloves-fr1183_s07_0.png",
"a17_necklace-fr1127_0": "a17_necklace-fr1127_s07_0.png",
"a17_orb-genmoba1_0": "a17_orb-genmoba1_s07_0.png",
"a17_helmet-genmoba0_0": "a17_helmet-genmoba0_s07_0.png",
"a17_staff-draw1_1": "a17_staff-draw1_s07_1.png",
"a17_blunt-fr1057_1": "a17_blunt-fr1057_s07_1.png",
"a17_totem-draw3_1": "a17_totem-draw3_s07_1.png",
"a17_belt-fr1351_1": "a17_belt-fr1351_s07_1.png",
"a19_sword-genxl1_1": "a19_sword-genxl1_s07_1.png",
"a19_ring-genxl0_1": "a19_ring-genxl0_s07_1.png",
"a19_quiver-genxl1_0": "a19_quiver-genxl1_s07_0.png",
"a19_shield-genxl1_0": "a19_shield-genxl1_s07_0.png",
"a20_belt-genxlb0_1": "a20_belt-genxlb0_s07_1.png",
"a21_necklace-genxlc0_2": "a21_necklace-genxlc0_s07_2.png",
"a21_spellbook-genxlc0_0": "a21_spellbook-genxlc0_s07_0.png"
}

View File

@ -0,0 +1,35 @@
{
"a14_armor-fr1267_1": "a14_armor-fr1267_s07_1.png",
"a14_belt-fr1351_0": "a14_belt-fr1351_s07_0.png",
"a14_boots-fr1379_1": "a14_boots-fr1379_s07_1.png",
"a14_gloves-fr1169_1": "a14_gloves-fr1169_s07_1.png",
"a14_necklace-fr1099_0": "a14_necklace-fr1099_s07_0.png",
"a14_shield-draw0_0": "a14_shield-draw0_s07_0.png",
"a14_totem-draw3_1": "a14_totem-draw3_s07_1.png",
"a17_armor-fr1253_1": "a17_armor-fr1253_s07_1.png",
"a17_armor-fr1267_1": "a17_armor-fr1267_s07_1.png",
"a17_boots-fr1393_0": "a17_boots-fr1393_s07_0.png",
"a17_gloves-fr1183_0": "a17_gloves-fr1183_s07_0.png",
"a17_necklace-fr1127_0": "a17_necklace-fr1127_s07_0.png",
"a17_orb-genmoba1_0": "a17_orb-genmoba1_s07_0.png",
"a17_helmet-genmoba0_0": "a17_helmet-genmoba0_s07_0.png",
"a17_staff-draw1_1": "a17_staff-draw1_s07_1.png",
"a17_blunt-fr1057_1": "a17_blunt-fr1057_s07_1.png",
"a17_totem-draw3_1": "a17_totem-draw3_s07_1.png",
"a17_belt-fr1351_1": "a17_belt-fr1351_s07_1.png",
"a19_sword-genxl1_1": "a19_sword-genxl1_s07_1.png",
"a19_ring-genxl0_1": "a19_ring-genxl0_s07_1.png",
"a19_quiver-genxl1_0": "a19_quiver-genxl1_s07_0.png",
"a19_shield-genxl1_0": "a19_shield-genxl1_s07_0.png",
"a20_belt-genxlb0_1": "a20_belt-genxlb0_s07_1.png",
"a21_necklace-genxlc0_2": "a21_necklace-genxlc0_s07_2.png",
"a21_spellbook-genxlc0_0": "a21_spellbook-genxlc0_s07_0.png",
"a23_axe-genxld1_1": "a23_axe-genxld1_s07_1.png",
"a23_hammer-genxld1_2": "a23_hammer-genxld1_s07_2.png",
"a23_helmet-genxld2_1": "a23_helmet-genxld2_s07_1.png",
"a23_helmet-genxld3_2": "a23_helmet-genxld3_s07_2.png",
"a23_quiver-genxld2_0": "a23_quiver-genxld2_s07_0.png",
"a23_ring-genxld1_0": "a23_ring-genxld1_s07_0.png",
"a23_ring-genxld3_0": "a23_ring-genxld3_s07_0.png",
"a23_shield-genxld3_0": "a23_shield-genxld3_s07_0.png"
}

View File

@ -0,0 +1,48 @@
{
"a14_armor-fr1267_1": "a14_armor-fr1267_s07_1.png",
"a14_belt-fr1351_0": "a14_belt-fr1351_s07_0.png",
"a14_boots-fr1379_1": "a14_boots-fr1379_s07_1.png",
"a14_gloves-fr1169_1": "a14_gloves-fr1169_s07_1.png",
"a14_necklace-fr1099_0": "a14_necklace-fr1099_s07_0.png",
"a14_shield-draw0_0": "a14_shield-draw0_s07_0.png",
"a14_totem-draw3_1": "a14_totem-draw3_s07_1.png",
"a17_armor-fr1253_1": "a17_armor-fr1253_s07_1.png",
"a17_armor-fr1267_1": "a17_armor-fr1267_s07_1.png",
"a17_boots-fr1393_0": "a17_boots-fr1393_s07_0.png",
"a17_gloves-fr1183_0": "a17_gloves-fr1183_s07_0.png",
"a17_necklace-fr1127_0": "a17_necklace-fr1127_s07_0.png",
"a17_orb-genmoba1_0": "a17_orb-genmoba1_s07_0.png",
"a17_helmet-genmoba0_0": "a17_helmet-genmoba0_s07_0.png",
"a17_staff-draw1_1": "a17_staff-draw1_s07_1.png",
"a17_blunt-fr1057_1": "a17_blunt-fr1057_s07_1.png",
"a17_totem-draw3_1": "a17_totem-draw3_s07_1.png",
"a17_belt-fr1351_1": "a17_belt-fr1351_s07_1.png",
"a19_sword-genxl1_1": "a19_sword-genxl1_s07_1.png",
"a19_ring-genxl0_1": "a19_ring-genxl0_s07_1.png",
"a19_quiver-genxl1_0": "a19_quiver-genxl1_s07_0.png",
"a19_shield-genxl1_0": "a19_shield-genxl1_s07_0.png",
"a20_belt-genxlb0_1": "a20_belt-genxlb0_s07_1.png",
"a21_necklace-genxlc0_2": "a21_necklace-genxlc0_s07_2.png",
"a21_spellbook-genxlc0_0": "a21_spellbook-genxlc0_s07_0.png",
"a23_axe-genxld1_1": "a23_axe-genxld1_s07_1.png",
"a23_hammer-genxld1_2": "a23_hammer-genxld1_s07_2.png",
"a23_helmet-genxld2_1": "a23_helmet-genxld2_s07_1.png",
"a23_helmet-genxld3_2": "a23_helmet-genxld3_s07_2.png",
"a23_quiver-genxld2_0": "a23_quiver-genxld2_s07_0.png",
"a23_ring-genxld1_0": "a23_ring-genxld1_s07_0.png",
"a23_ring-genxld3_0": "a23_ring-genxld3_s07_0.png",
"a23_shield-genxld3_0": "a23_shield-genxld3_s07_0.png",
"a24_axe-genxld1_0": "a24_axe-genxld1_s07_0.png",
"a24_dagger-genxlb0_3": "a24_dagger-genxlb0_s07_3.png",
"a24_dagger-genxlb0_2": "a24_dagger-genxlb0_s07_2.png",
"a24_hammer-genxld0_3": "a24_hammer-genxld0_s07_3.png",
"a24_hammer-genxld1_3": "a24_hammer-genxld1_s07_3.png",
"a24_horn-draw2_0": "a24_horn-draw2_s07_0.png",
"a24_horn-draw2_2": "a24_horn-draw2_s07_2.png",
"a24_orb-genmoba1_1": "a24_orb-genmoba1_s07_1.png",
"a24_orb-genmoba1_0": "a24_orb-genmoba1_s07_0.png",
"a24_spellbook-genxlc0_0": "a24_spellbook-genxlc0_s07_0.png",
"a24_spellbook-genxlc0_3": "a24_spellbook-genxlc0_s07_3.png",
"a24_sword-genxl1_1": "a24_sword-genxl1_s07_1.png",
"a24_sword-genxl1_0": "a24_sword-genxl1_s07_0.png"
}

View File

@ -0,0 +1 @@
["spellbook_01", "spellbook_02", "helmet_02", "dagger_01", "throwing_01", "bow_01", "staff_02"]

Binary file not shown.

After

Width:  |  Height:  |  Size: 776 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 714 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 827 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 331 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 347 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 410 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 746 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 729 KiB

Some files were not shown because too many files have changed in this diff Show More