| |
| """WD14 tagger — danbooru-теги для аниме-картинок (SmilingWolf/wd-*-tagger-v3). |
| |
| Пишет теги в <имя_файла>.txt (danbooru-стиль: "1girl, animal ears, ..."). |
| Уже размеченные файлы пропускает (--force — перезаписать). |
| |
| Usage: |
| python wd14_tagger.py <папка_или_файл>... [--model vit] [--gen 0.5] [--char 0.85] |
| |
| Зависимости: torch, timm, pandas, pillow, huggingface_hub. Нужен GPU (или CPU). |
| """ |
|
|
| import argparse |
| import time |
| from dataclasses import dataclass |
| from pathlib import Path |
|
|
| import numpy as np |
| import pandas as pd |
| import timm |
| import torch |
| from huggingface_hub import hf_hub_download |
| from huggingface_hub.utils import HfHubHTTPError |
| from PIL import Image |
| from timm.data import create_transform, resolve_data_config |
| from torch import nn |
| from torch.nn import functional as F |
| from tqdm import tqdm |
|
|
| MODEL_REPO_MAP = { |
| "vit": "SmilingWolf/wd-vit-tagger-v3", |
| "swinv2": "SmilingWolf/wd-swinv2-tagger-v3", |
| "convnext": "SmilingWolf/wd-convnext-tagger-v3", |
| } |
|
|
| IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".bmp"} |
|
|
|
|
| def pil_ensure_rgb(image: Image.Image) -> Image.Image: |
| if image.mode not in ("RGB", "RGBA"): |
| image = image.convert("RGBA" if "transparency" in image.info else "RGB") |
| if image.mode == "RGBA": |
| canvas = Image.new("RGBA", image.size, (255, 255, 255)) |
| canvas.alpha_composite(image) |
| image = canvas.convert("RGB") |
| return image |
|
|
|
|
| def pil_pad_square(image: Image.Image) -> Image.Image: |
| w, h = image.size |
| px = max(image.size) |
| canvas = Image.new("RGB", (px, px), (255, 255, 255)) |
| canvas.paste(image, ((px - w) // 2, (px - h) // 2)) |
| return canvas |
|
|
|
|
| @dataclass |
| class LabelData: |
| names: list |
| rating: list |
| general: list |
| character: list |
|
|
|
|
| def load_labels_hf(repo_id: str) -> LabelData: |
| try: |
| csv_path = hf_hub_download(repo_id=repo_id, filename="selected_tags.csv") |
| except HfHubHTTPError as e: |
| raise FileNotFoundError(f"selected_tags.csv failed to download from {repo_id}") from e |
| df = pd.read_csv(csv_path, usecols=["name", "category"]) |
| return LabelData( |
| names=df["name"].tolist(), |
| rating=list(np.where(df["category"] == 9)[0]), |
| general=list(np.where(df["category"] == 0)[0]), |
| character=list(np.where(df["category"] == 4)[0]), |
| ) |
|
|
|
|
| def get_tags(probs, labels, gen_threshold, char_threshold): |
| """Probs -> строка тегов (danbooru-стиль, рейтинг в конце).""" |
| probs = list(zip(labels.names, probs.numpy())) |
| rating = dict(sorted((probs[i] for i in labels.rating), key=lambda x: x[1], reverse=True)) |
| gen = {n: p for n, p in (probs[i] for i in labels.general) if p > gen_threshold} |
| char = {n: p for n, p in (probs[i] for i in labels.character) if p > char_threshold} |
| gen = dict(sorted(gen.items(), key=lambda x: x[1], reverse=True)) |
| char = dict(sorted(char.items(), key=lambda x: x[1], reverse=True)) |
| tags = list(gen) + list(char) |
| caption = ", ".join(t.replace("_", " ").replace("(", r"\(").replace(")", r"\)") for t in tags) |
| caption += ", rating_" + next(iter(rating), "") |
| return caption |
|
|
|
|
| def main(): |
| parser = argparse.ArgumentParser(description="WD14 tagger (danbooru-теги)") |
| parser.add_argument("paths", nargs="+", help="папки или файлы (папки обходятся рекурсивно)") |
| parser.add_argument("--model", default="vit", choices=sorted(MODEL_REPO_MAP)) |
| parser.add_argument("--gen", dest="gen_threshold", type=float, default=0.5, |
| help="порог уверенности для general-тегов") |
| parser.add_argument("--char", dest="char_threshold", type=float, default=0.85, |
| help="порог уверенности для character-тегов") |
| parser.add_argument("--force", action="store_true", help="перезаписать существующие .txt") |
| args = parser.parse_args() |
|
|
| repo_id = MODEL_REPO_MAP[args.model] |
| device = torch.device("cuda" if torch.cuda.is_available() else "cpu") |
|
|
| |
| files = [] |
| for arg in args.paths: |
| p = Path(arg) |
| if p.is_dir(): |
| files.extend(f for f in p.rglob("*") if f.suffix.lower() in IMAGE_EXTS) |
| elif p.is_file(): |
| files.append(p) |
| files = sorted(set(files)) |
| if not files: |
| print("Нет файлов для обработки.") |
| return |
|
|
| print(f"Загрузка модели {args.model} ({repo_id}) на {device}...") |
| model = timm.create_model("hf-hub:" + repo_id).eval() |
| model.load_state_dict(timm.models.load_state_dict_from_hf(repo_id)) |
| labels = load_labels_hf(repo_id) |
| transform = create_transform(**resolve_data_config(model.pretrained_cfg, model=model)) |
| if device.type == "cuda": |
| model = model.to(device) |
|
|
| t0 = time.time() |
| done = skipped = 0 |
| for image_path in tqdm(files, desc="Tagging", unit="img"): |
| txt_path = image_path.with_suffix(".txt") |
| if txt_path.exists() and not args.force: |
| skipped += 1 |
| continue |
| try: |
| img = pil_pad_square(pil_ensure_rgb(Image.open(image_path))) |
| inputs = transform(img).unsqueeze(0)[:, [2, 1, 0]] |
| with torch.inference_mode(): |
| outputs = F.sigmoid(model(inputs.to(device))).cpu() |
| caption = get_tags(outputs.squeeze(0), labels, args.gen_threshold, args.char_threshold) |
| txt_path.write_text(caption + "\n") |
| done += 1 |
| except Exception as e: |
| tqdm.write(f" SKIP {image_path.name}: {e}") |
|
|
| print(f"\nГотово: {done} размечено, {skipped} пропущено (уже есть .txt), " |
| f"за {time.strftime('%H:%M:%S', time.gmtime(time.time() - t0))}") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|