| |
| """danbooru-tagger-v1 + wd-eva02-large-tagger-v3, blended. |
| |
| This is the configuration that actually beats WD on ranking quality. On 11,639 |
| held-out Danbooru posts with id > 8,600,750 (after every incumbent's training |
| cutoff), against wd-eva02-large-tagger-v3, paired bootstrap over images: |
| |
| macro-AP 0.5090 vs 0.4668 +0.0421 95% CI [+0.039, +0.044] |
| fine AP 0.6113 vs 0.5979 +0.0133 95% CI [+0.002, +0.022] |
| |
| Either model alone loses one of those. The two disagree in useful ways |
| (probability correlation r = 0.876): v1 is stronger on tags WD handles badly, |
| WD is stronger on tags it handles well, and averaging keeps both. |
| |
| Cost: two forward passes instead of one. If you only want a single model, use |
| predict.py -- it wins macro-F1 and coverage but ties macro-AP and loses fine AP. |
| |
| pip install torch timm pillow numpy huggingface_hub |
| python ensemble.py image.png --thr 0.38 |
| """ |
| import argparse, csv, os |
| import numpy as np |
| import torch |
| from PIL import Image |
|
|
| SIDE = 448 |
| WD_REPO = "SmilingWolf/wd-eva02-large-tagger-v3" |
|
|
|
|
| def to_rgb(im): |
| if im.mode in ("RGBA", "LA", "P"): |
| im = im.convert("RGBA") |
| bg = Image.new("RGBA", im.size, (255, 255, 255)) |
| bg.alpha_composite(im) |
| return bg.convert("RGB") |
| return im.convert("RGB") |
|
|
|
|
| def pad_square(im): |
| w, h = im.size |
| if w == h: |
| return im |
| s = max(w, h) |
| bg = Image.new("RGB", (s, s), (255, 255, 255)) |
| bg.paste(im, ((s - w) // 2, (s - h) // 2)) |
| return bg |
|
|
|
|
| def preprocess(path): |
| """Both models share this exactly: white pad, bicubic 448, [-1,1], RGB->BGR.""" |
| im = pad_square(to_rgb(Image.open(path))).resize((SIDE, SIDE), Image.BICUBIC) |
| x = torch.from_numpy(np.asarray(im, dtype=np.float32) / 255.0).permute(2, 0, 1) |
| x = (x - 0.5) / 0.5 |
| return x[[2, 1, 0]][None] |
|
|
|
|
| def snapshot_download_wd(): |
| from huggingface_hub import snapshot_download |
| return snapshot_download(WD_REPO, allow_patterns=["config.json", "model.safetensors", |
| "selected_tags.csv"]) |
|
|
|
|
| def load_wd(device, path): |
| import json, timm |
| from safetensors.torch import load_file |
| cfg = json.load(open(f"{path}/config.json")) |
| m = timm.create_model(cfg["architecture"], pretrained=False, |
| num_classes=cfg["num_classes"], **cfg.get("model_args", {})) |
| missing, unexpected = m.load_state_dict(load_file(f"{path}/model.safetensors"), strict=False) |
| if missing or unexpected: |
| raise SystemExit(f"WD weights did not load cleanly: {missing[:3]} {unexpected[:3]}") |
| rows = list(csv.DictReader(open(f"{path}/selected_tags.csv"))) |
| |
| |
| |
| |
| return m.eval().to(device), [r["name"] if int(r["category"]) == 0 else None |
| for r in rows] |
|
|
|
|
| def load_ours(device, ckpt, base_arch_from): |
| import timm, json |
| ck = torch.load(ckpt, map_location="cpu", weights_only=False) |
| cfg = json.load(open(f"{base_arch_from}/config.json")) |
| m = timm.create_model(cfg["architecture"], pretrained=False, |
| num_classes=len(ck["tags"]), **cfg.get("model_args", {})) |
| m.load_state_dict({k: v.float() for k, v in ck["model"].items()}) |
| return m.eval().to(device), list(ck["tags"]) |
|
|
|
|
| def main(): |
| here = os.path.dirname(os.path.abspath(__file__)) |
| ap = argparse.ArgumentParser() |
| ap.add_argument("images", nargs="+") |
| ap.add_argument("--ckpt", default=os.path.join(here, "weights.fp16.pt")) |
| ap.add_argument("--thr", type=float, default=0.38) |
| ap.add_argument("--w", type=float, default=0.5, help="weight on our model; 0.5 is what was measured") |
| ap.add_argument("--wd-path", default="", help="local dir holding WD's config.json/model.safetensors/" |
| "selected_tags.csv, instead of downloading from the Hub") |
| a = ap.parse_args() |
|
|
| dev = "cuda" if torch.cuda.is_available() else "cpu" |
| wd_path = a.wd_path or snapshot_download_wd() |
| wd, wd_tags = load_wd(dev, wd_path) |
| ours, our_tags = load_ours(dev, a.ckpt, wd_path) |
|
|
| our_idx = {t: i for i, t in enumerate(our_tags)} |
| |
| shared = [(our_idx[t], j) for j, t in enumerate(wd_tags) if t is not None and t in our_idx] |
| oi = np.array([s[0] for s in shared]); wi = np.array([s[1] for s in shared]) |
| print(f"{len(our_tags):,} tags, {len(shared):,} shared with WD -> blended; " |
| f"{len(our_tags)-len(shared):,} ours only") |
|
|
| for path in a.images: |
| x = preprocess(path).to(dev) |
| with torch.inference_mode(): |
| p = torch.sigmoid(ours(x).float())[0].cpu().numpy() |
| q = torch.sigmoid(wd(x).float())[0].cpu().numpy() |
| p[oi] = a.w * p[oi] + (1.0 - a.w) * q[wi] |
| order = np.argsort(-p) |
| hit = [(our_tags[i], float(p[i])) for i in order if p[i] >= a.thr] |
| print(f"\n=== {path} ({len(hit)} tags >= {a.thr})") |
| print(", ".join(f"{t}:{v:.2f}" for t, v in hit) or "(nothing above threshold)") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|