File size: 19,310 Bytes
37389b6 e600268 37389b6 e600268 37389b6 e600268 37389b6 e600268 37389b6 e600268 37389b6 e600268 37389b6 e600268 37389b6 e600268 37389b6 e600268 37389b6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 | #!/usr/bin/env python3
"""Booru Lighting Tagger: lighting tags for anime-style illustrations.
Command line (run from anywhere; the model files are read from this script's folder):
python inference.py image.png other.jpg # print the tags
python inference.py dataset/ --recursive --append-to-captions
python inference.py dataset/ --jsonl results.jsonl # every score, for your own thresholds
python inference.py dataset/ -r --append-to-captions --preset recall # more tags, a little less precise
Python:
from inference import LightingTagger
tagger = LightingTagger() # model files next to this script
for result in tagger.tag_files(["image.png"]):
print(result["tags"], result["scores"]["backlighting"])
The model is three fine-tuned copies ("seeds") of SmilingWolf/wd-eva02-large-tagger-v3. Each weight file
holds only the parameters that were trained (last 16 transformer blocks, final norm, new head); everything
else is loaded from the base model, pinned to the revision used for training. Scores are averaged over the
seeds. See README.md for the labels and their reliability.
Threshold presets: "default" emits as many tags as the teacher did on the validation images; "recall" emits
about 1.5 times as many (see README.md, "Threshold presets").
"""
from __future__ import annotations
import argparse
import contextlib
import copy
import json
import sys
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from typing import Iterable, Iterator
import numpy as np
import torch
from PIL import Image
CONFIG_FILE = "tagger_config.json"
PRESETS = ("default", "recall")
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif", ".tif", ".tiff"}
WHITE = (255, 255, 255)
# --- Preprocessing: identical to training, do not change ------------------------------------------------
def to_rgb(image: Image.Image) -> Image.Image:
"""RGB image; transparency is flattened onto white."""
if image.mode not in ("RGB", "RGBA"):
image = image.convert("RGBA" if "transparency" in image.info else "RGB")
if image.mode == "RGBA":
background = Image.new("RGBA", image.size, (255, 255, 255, 255))
image = Image.alpha_composite(background, image).convert("RGB")
return image
def pad_square(image: Image.Image) -> Image.Image:
"""Pads to a square on white, centered, without cropping: the whole lighting stays visible."""
side = max(image.size)
square = Image.new("RGB", (side, side), WHITE)
square.paste(image, ((side - image.width) // 2, (side - image.height) // 2))
return square
def prepare(image: Image.Image, transform) -> torch.Tensor:
"""WD preprocessing: white square padding, the base model's timm transform, RGB -> BGR."""
return transform(pad_square(to_rgb(image)))[[2, 1, 0]]
# --- Model ----------------------------------------------------------------------------------------------
class _Student(torch.nn.Module):
"""WD backbone with a new linear head (parameter names match the weight files)."""
def __init__(self, backbone, n_outputs: int):
super().__init__()
self.backbone = backbone
self.head = torch.nn.Linear(backbone.num_features, n_outputs)
def forward(self, x):
return self.head(self.backbone.forward_head(self.backbone.forward_features(x), pre_logits=True))
def _load_base(repo: str, revision: str):
"""Base WD tagger at the pinned revision, on the CPU, and its timm eval transform."""
import timm
ref = f"{repo}@{revision}"
model = timm.create_model(f"hf-hub:{ref}").eval()
model.load_state_dict(timm.models.load_state_dict_from_hf(ref))
transform = timm.data.create_transform(**timm.data.resolve_data_config(model.pretrained_cfg, model=model))
return model, transform
def _trained_parameters(model: _Student, blocks: int) -> set[str]:
depth = len(model.backbone.blocks)
prefixes = tuple(f"backbone.blocks.{i}." for i in range(depth - blocks, depth)) + ("backbone.fc_norm.", "head.")
return {name for name, _ in model.named_parameters() if name.startswith(prefixes)}
def _load_seed(base, path: Path, n_outputs: int, blocks: int) -> _Student:
"""A copy of the base model with the trained parameters of one seed loaded on top."""
from safetensors.torch import load_file
model = _Student(copy.deepcopy(base), n_outputs)
state = load_file(str(path), device="cpu")
expected = _trained_parameters(model, blocks)
if set(state) != expected:
missing, extra = sorted(expected - set(state)), sorted(set(state) - expected)
raise ValueError(f"{path.name}: unexpected parameters (missing {missing[:5]}, extra {extra[:5]})")
model.load_state_dict(state, strict=False)
return model.eval()
def _decode(logits: np.ndarray, n_labels: int, steps: int) -> tuple[np.ndarray, np.ndarray]:
"""Raw outputs -> (scores 0-10, tonal key probabilities).
Ordinal target: for each label, `steps` outputs give P(score >= t) for t = 1..steps; the predicted score
is their sum. The last outputs are the tonal key logits (softmax)."""
width = n_labels * steps
sigmoid = 1 / (1 + np.exp(-logits[:, :width].astype(np.float64)))
scores = sigmoid.reshape(-1, n_labels, steps).sum(-1)
keys = logits[:, width:].astype(np.float64)
keys = np.exp(keys - keys.max(1, keepdims=True))
return scores.astype(np.float32), (keys / keys.sum(1, keepdims=True)).astype(np.float32)
def _resolve_device(device: str) -> torch.device:
if device == "auto":
if torch.cuda.is_available():
device = "cuda"
elif getattr(torch.backends, "mps", None) and torch.backends.mps.is_available():
device = "mps"
else:
device = "cpu"
return torch.device(device)
def _autocast_dtype(device: torch.device, precision: str):
"""Mixed precision on CUDA only (bf16, as in training; fp16 on GPUs without bf16)."""
if device.type != "cuda" or precision == "fp32":
return None
if precision == "bf16":
return torch.bfloat16
if precision == "fp16":
return torch.float16
return torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16
class LightingTagger:
"""Loads the seeds once; `tag_images` and `tag_files` return one result dict per image."""
def __init__(self, model_dir: str | Path | None = None, device: str = "auto", seeds: int | None = None,
precision: str = "auto", preset: str = "default"):
self.model_dir = Path(model_dir) if model_dir else Path(__file__).resolve().parent
config_path = self.model_dir / CONFIG_FILE
if not config_path.exists():
raise FileNotFoundError(f"{config_path} not found: pass the folder that holds the model files")
self.config = json.loads(config_path.read_text(encoding="utf-8"))
self.version = self.config["version"]
self.labels = [label["name"] for label in self.config["labels"]]
# Configs older than v1.0.1 have no recall thresholds: the default ones are used instead.
self.presets = {
"default": np.array([label["threshold"] for label in self.config["labels"]], dtype=np.float32),
"recall": np.array([label.get("threshold_recall", label["threshold"]) for label in self.config["labels"]],
dtype=np.float32),
}
if preset not in self.presets:
raise ValueError(f"preset must be one of {PRESETS}")
self.preset = preset
self.thresholds = self.presets[preset]
self.status = {label["name"]: label["reliability"]["status"] for label in self.config["labels"]}
self.keys = [key["name"] for key in self.config["tonal_keys"]]
self.steps = int(self.config["ordinal_steps"])
self.device = _resolve_device(device)
self.dtype = _autocast_dtype(self.device, precision)
all_seeds = self.config["seeds"]
if seeds is not None and not 1 <= seeds <= len(all_seeds):
raise ValueError(f"seeds must be between 1 and {len(all_seeds)}")
base_model = self.config["base_model"]
base, self.transform = _load_base(base_model["repo"], base_model["revision"])
n_outputs = len(self.labels) * self.steps + len(self.keys)
self.models = [_load_seed(base, self.model_dir / seed["file"], n_outputs, self.config["trained_blocks"])
.to(self.device) for seed in all_seeds[:seeds]]
del base
@classmethod
def from_pretrained(cls, repo_id: str, revision: str | None = None, **kwargs) -> LightingTagger:
"""Downloads the model from the Hugging Face Hub (cached), then loads it."""
from huggingface_hub import snapshot_download
return cls(snapshot_download(repo_id, revision=revision), **kwargs)
# --- Inference --------------------------------------------------------------------------------------
@torch.inference_mode()
def predict(self, batch: torch.Tensor) -> tuple[np.ndarray, np.ndarray]:
"""Preprocessed batch -> (scores: images x labels, tonal key probabilities: images x 4), seed average."""
batch = batch.to(self.device)
scores, keys = [], []
for model in self.models:
autocast = (torch.autocast(device_type="cuda", dtype=self.dtype) if self.dtype is not None
else contextlib.nullcontext())
with autocast:
logits = model(batch).float().cpu().numpy()
s, k = _decode(logits, len(self.labels), self.steps)
scores.append(s)
keys.append(k)
return np.mean(scores, axis=0), np.mean(keys, axis=0)
def format(self, scores: np.ndarray, key_probs: np.ndarray, validated_only: bool = False,
tonal_key_tag: bool = True, preset: str | None = None) -> dict:
"""One image: tags (by descending score, then the tonal key), all scores and the tonal key.
`preset` overrides the tagger's threshold preset for this call ("default" or "recall")."""
thresholds = self.thresholds if preset is None else self.presets[preset]
tagged = [k for k in np.flatnonzero(scores >= thresholds)
if not validated_only or self.status[self.labels[k]] == "validated"]
tags = [self.labels[k] for k in sorted(tagged, key=lambda k: -scores[k])]
key = self.keys[int(np.argmax(key_probs))]
if tonal_key_tag and key != "normal":
tags.append(key)
return {
"tags": tags,
"scores": {name: round(float(s), 3) for name, s in zip(self.labels, scores)},
"tonal_key": key,
"tonal_key_probabilities": {name: round(float(p), 4) for name, p in zip(self.keys, key_probs)},
}
def tag_images(self, images: list[Image.Image], **options) -> list[dict]:
"""PIL images -> one result per image (see `format`)."""
if not images:
return []
scores, keys = self.predict(torch.stack([prepare(image, self.transform) for image in images]))
return [self.format(s, k, **options) for s, k in zip(scores, keys)]
def tag_files(self, paths: Iterable[str | Path], batch_size: int = 8, workers: int = 4,
**options) -> Iterator[dict]:
"""Image files -> one result per file, in order, as they are computed. An unreadable file gets an
`error` entry and does not stop the run."""
def load(path: Path):
try:
with Image.open(path) as image:
return prepare(image, self.transform)
except Exception as exc: # noqa: BLE001: report and go on
return f"{type(exc).__name__}: {exc}"
paths = [Path(p) for p in paths]
with ThreadPoolExecutor(max_workers=max(1, workers)) as pool:
for start in range(0, len(paths), batch_size):
chunk = paths[start:start + batch_size]
loaded = list(pool.map(load, chunk))
ok = [k for k, item in enumerate(loaded) if not isinstance(item, str)]
results = {}
if ok:
scores, keys = self.predict(torch.stack([loaded[k] for k in ok]))
results = {k: self.format(s, p, **options) for k, s, p in zip(ok, scores, keys)}
for k, path in enumerate(chunk):
if k in results:
yield {"file": str(path), **results[k], "error": None}
else:
yield {"file": str(path), "tags": [], "scores": None, "tonal_key": None,
"tonal_key_probabilities": None, "error": loaded[k]}
# --- Captions -------------------------------------------------------------------------------------------
def _normalize(tag: str) -> str:
return tag.strip().lower().replace("_", " ")
def caption_path(image: Path, extension: str) -> Path:
return image.parent / (image.stem + extension)
def append_to_caption(path: Path, tags: list[str]) -> int:
"""Adds the tags missing from a comma-separated caption file (created if needed). Returns the count."""
text = path.read_text(encoding="utf-8") if path.exists() else ""
present = {_normalize(tag) for tag in text.split(",") if tag.strip()}
new = [tag for tag in tags if _normalize(tag) not in present]
if new:
text = text.rstrip()
path.write_text((text + ", " if text else "") + ", ".join(new), encoding="utf-8")
return len(new)
# --- Command line ---------------------------------------------------------------------------------------
def find_images(inputs: list[str], recursive: bool) -> list[Path]:
paths = []
for item in map(Path, inputs):
if item.is_dir():
pattern = "**/*" if recursive else "*"
paths += sorted(p for p in item.glob(pattern) if p.is_file() and p.suffix.lower() in IMAGE_EXTENSIONS)
elif item.is_file():
paths.append(item)
else:
print(f"not found: {item}", file=sys.stderr)
return paths
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="Booru Lighting Tagger: adds lighting tags (backlighting, rim_light, sunset...) to images.")
parser.add_argument("inputs", nargs="+", help="image files and/or folders")
parser.add_argument("-r", "--recursive", action="store_true", help="look into subfolders")
parser.add_argument("--jsonl", type=Path, help="write every result (all scores) to this JSON Lines file")
parser.add_argument("--append-to-captions", nargs="?", const=".txt", metavar="EXT",
help="add the missing lighting tags to each image's caption file "
"(<image name>EXT, default .txt; created if missing)")
parser.add_argument("--write-captions", nargs="?", const=".lighting.txt", metavar="EXT",
help="write the lighting tags alone to <image name>EXT (default .lighting.txt), "
"replacing that file")
parser.add_argument("--spaces", action="store_true",
help="write tags with spaces instead of underscores (rim light, not rim_light)")
parser.add_argument("--validated-only", action="store_true",
help="only emit the labels validated against human answers (see README)")
parser.add_argument("--preset", choices=PRESETS, default="default",
help="threshold preset: default (as many tags as the teacher) or recall (about 1.5 times "
"as many tags, a little less precise; see README)")
parser.add_argument("--no-tonal-key", action="store_true",
help="do not emit the tonal key tag (dark, dim_lighting, high_key)")
parser.add_argument("--seeds", type=int, help="number of seeds to use, 1 to 3 (default 3; fewer is faster "
"and slightly less accurate)")
parser.add_argument("--batch-size", type=int, default=8)
parser.add_argument("--workers", type=int, default=4, help="image decoding threads")
parser.add_argument("--device", default="auto", help="auto, cuda, cuda:1, mps or cpu")
parser.add_argument("--precision", default="auto", choices=("auto", "bf16", "fp16", "fp32"),
help="CUDA only (auto: bf16 when supported, as in training)")
parser.add_argument("--model-dir", type=Path, help="folder with the model files (default: this script's)")
parser.add_argument("--repo", help="download the model from this Hugging Face repo instead")
parser.add_argument("--revision", help="with --repo: a tag such as v1.0.0, a branch or a commit")
parser.add_argument("-q", "--quiet", action="store_true", help="do not print a line per image")
args = parser.parse_args(argv)
for stream in (sys.stdout, sys.stderr):
with contextlib.suppress(AttributeError, ValueError):
stream.reconfigure(encoding="utf-8", errors="replace")
paths = find_images(args.inputs, args.recursive)
if not paths:
print("no image found", file=sys.stderr)
return 1
options = {"device": args.device, "seeds": args.seeds, "precision": args.precision, "preset": args.preset}
tagger = (LightingTagger.from_pretrained(args.repo, revision=args.revision, **options) if args.repo
else LightingTagger(args.model_dir, **options))
print(f"booru-lighting-tagger {tagger.version}: {len(tagger.models)} seed(s) on {tagger.device}, "
f"{tagger.preset} thresholds, {len(paths)} image(s)", file=sys.stderr)
jsonl = args.jsonl.open("w", encoding="utf-8") if args.jsonl else None
done = errors = tagged_images = added = 0
try:
for result in tagger.tag_files(paths, batch_size=args.batch_size, workers=args.workers,
validated_only=args.validated_only, tonal_key_tag=not args.no_tonal_key):
done += 1
if jsonl:
jsonl.write(json.dumps(result, ensure_ascii=False) + "\n")
if result["error"]:
errors += 1
print(f"{result['file']}: error: {result['error']}", file=sys.stderr)
continue
tags = [tag.replace("_", " ") for tag in result["tags"]] if args.spaces else result["tags"]
tagged_images += bool(tags)
image = Path(result["file"])
if args.append_to_captions:
added += append_to_caption(caption_path(image, args.append_to_captions), tags)
if args.write_captions:
caption_path(image, args.write_captions).write_text(", ".join(tags), encoding="utf-8")
if not args.quiet:
print(f"{result['file']}: {', '.join(tags) if tags else '(no lighting tag)'}")
finally:
if jsonl:
jsonl.close()
summary = f"{done} image(s), {tagged_images} with at least one tag, {errors} error(s)"
if args.append_to_captions:
summary += f", {added} tag(s) added to captions"
print(summary, file=sys.stderr)
return 0 if errors == 0 else 2
if __name__ == "__main__":
sys.exit(main())
|