danbooru-tagger-v1 / ensemble.py
Redstonexs's picture
ensemble: restrict blend to general tags, matching the measured comparison
b599833 verified
Raw
History Blame Contribute Delete
5.38 kB
#!/usr/bin/env python
"""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")))
# general tags only (category 0). The published comparison was computed over
# each model's general vocabulary, so blending WD's character/copyright rows
# here would not match the numbers in the README -- 'chocobo' is general in
# our vocabulary and category 4 in WD's, and is the one tag this excludes.
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)}
# blend only where both models have the tag; elsewhere ours stands alone
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()