Download inference.py from MarioTheKnight/booru-lighting-tagger: direct link, hf CLI and curl.
- Browser
- Download file 19.3 kB
-
https://huggingface.co/MarioTheKnight/booru-lighting-tagger/resolve/main/inference.py
- Command line
-
hf download hf://MarioTheKnight/booru-lighting-tagger/inference.py
-
curl -L -o inference.py https://huggingface.co/MarioTheKnight/booru-lighting-tagger/resolve/main/inference.py
19.3 kB
| #!/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 | |
| 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 -------------------------------------------------------------------------------------- | |
| 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()) | |