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())