Anime_Image2Prompt / inference.py
damoncao's picture
Upload folder using huggingface_hub
6b19e2a verified
Raw
History Blame Contribute Delete
9.39 kB
"""Local inference for Anime_Image2Prompt.
The model and processor are loaded only from the directory containing this
file. No base model, adapter, or network download is required.
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import Any
import torch
from PIL import Image, ImageOps
from transformers import AutoModelForMultimodalLM, AutoProcessor
MODEL_DIR = Path(__file__).resolve().parent
INFERENCE_CONFIG = MODEL_DIR / "inference_config.json"
TAG_FIELDS = ("general_tags", "character_tags", "copyright_tags")
REQUIRED_MODEL_FILES = (
"config.json",
"model.safetensors",
"processor_config.json",
"tokenizer.json",
"tokenizer_config.json",
"chat_template.jinja",
"inference_config.json",
)
def _validate_model_directory(model_dir: Path) -> None:
missing = [name for name in REQUIRED_MODEL_FILES if not (model_dir / name).is_file()]
if missing:
raise FileNotFoundError(
f"Incomplete Anime_Image2Prompt package; missing: {', '.join(missing)}"
)
def _select_runtime(device: str) -> tuple[str, torch.dtype]:
cuda_available = torch.cuda.is_available()
if device == "cuda" and not cuda_available:
raise RuntimeError(
"CUDA was requested but is unavailable. Install a CUDA-enabled PyTorch "
"build and verify the NVIDIA driver, or use --device cpu."
)
use_cuda = device == "cuda" or (device == "auto" and cuda_available)
if not use_cuda:
return "cpu", torch.float32
supports_bf16 = getattr(torch.cuda, "is_bf16_supported", lambda: False)()
return "cuda", torch.bfloat16 if supports_bf16 else torch.float16
def _parse_model_json(text: str) -> dict[str, list[str]]:
start = text.find("{")
if start < 0:
raise ValueError(f"The model did not return JSON. Raw output: {text!r}")
try:
payload, _ = json.JSONDecoder().raw_decode(text[start:])
except json.JSONDecodeError as error:
raise ValueError(f"The model returned invalid JSON. Raw output: {text!r}") from error
if not isinstance(payload, dict) or set(payload) != set(TAG_FIELDS):
actual = tuple(payload.keys()) if isinstance(payload, dict) else type(payload).__name__
raise ValueError(f"Unexpected model output fields: {actual!r}")
normalized: dict[str, list[str]] = {}
for field in TAG_FIELDS:
value = payload[field]
if not isinstance(value, list) or len(value) > 1:
raise ValueError(f"{field} must be [] or a one-string list: {value!r}")
if value:
if not isinstance(value[0], str):
raise ValueError(f"{field} must contain a string: {value!r}")
tags = value[0].split(",")
if any(not tag for tag in tags):
raise ValueError(f"{field} contains an empty tag: {value!r}")
if len(tags) != len(set(tags)):
raise ValueError(f"{field} contains duplicate tags: {value!r}")
normalized[field] = value
return normalized
def _move_inputs(inputs: dict[str, Any], device: torch.device) -> dict[str, Any]:
return {
key: value.to(device) if hasattr(value, "to") else value
for key, value in inputs.items()
}
class AnimeImage2Prompt:
"""Reusable local inference session for Anime_Image2Prompt."""
def __init__(self, device: str = "auto", low_vram: bool = False) -> None:
_validate_model_directory(MODEL_DIR)
runtime, dtype = _select_runtime(device)
self.runtime = runtime
self.dtype = dtype
self.config = json.loads(INFERENCE_CONFIG.read_text(encoding="utf-8"))
if runtime == "cuda":
device_map: str | dict[str, str] = "auto"
else:
device_map = {"": "cpu"}
self.processor = AutoProcessor.from_pretrained(
MODEL_DIR,
local_files_only=True,
)
if low_vram:
pixels = self.config["image_pixels"]
self.processor.image_processor.size = {
"shortest_edge": pixels["min"],
"longest_edge": pixels["low_vram_max"],
}
self.model = AutoModelForMultimodalLM.from_pretrained(
MODEL_DIR,
dtype=dtype,
device_map=device_map,
attn_implementation="sdpa",
local_files_only=True,
).eval()
@property
def device(self) -> torch.device:
return self.model.device
def predict(
self,
image: str | Path | Image.Image,
max_new_tokens: int = 1024,
) -> dict[str, list[str]]:
"""Generate structured prompt tags for one image."""
if isinstance(image, Image.Image):
prepared_image = ImageOps.exif_transpose(image).convert("RGB")
else:
image_path = Path(image).expanduser().resolve()
if not image_path.is_file():
raise FileNotFoundError(f"Input image does not exist: {image_path}")
with Image.open(image_path) as source:
prepared_image = ImageOps.exif_transpose(source).convert("RGB")
prepared_image.load()
messages = [
{
"role": "system",
"content": [{"type": "text", "text": self.config["system_prompt"]}],
},
{
"role": "user",
"content": [
{"type": "image", "image": prepared_image},
{"type": "text", "text": self.config["user_prompt"]},
],
},
]
inputs = self.processor.apply_chat_template(
messages,
tokenize=True,
add_generation_prompt=True,
enable_thinking=False,
return_dict=True,
return_tensors="pt",
)
inputs = _move_inputs(inputs, self.device)
with torch.inference_mode():
output_ids = self.model.generate(
**inputs,
max_new_tokens=max_new_tokens,
do_sample=False,
eos_token_id=self.config["assistant_end_token_id"],
pad_token_id=self.processor.tokenizer.pad_token_id,
)
prompt_length = inputs["input_ids"].shape[1]
generated_ids = output_ids[:, prompt_length:]
raw_text = self.processor.batch_decode(
generated_ids,
skip_special_tokens=True,
clean_up_tokenization_spaces=False,
)[0]
return _parse_model_json(raw_text)
@staticmethod
def to_prompt(payload: dict[str, list[str]]) -> str:
"""Flatten structured output to a comma-and-space-separated prompt."""
tags: list[str] = []
for field in TAG_FIELDS:
values = payload.get(field, [])
if values:
tags.extend(values[0].split(","))
return ", ".join(tags)
def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Generate Danbooru-style prompt tags with Anime_Image2Prompt."
)
parser.add_argument("image", type=Path, help="path to one input image")
parser.add_argument(
"--output",
type=Path,
help="optional output file; the result is always printed to stdout",
)
parser.add_argument(
"--format",
choices=("json", "prompt"),
default="json",
help="output format (default: json)",
)
parser.add_argument(
"--device",
choices=("auto", "cuda", "cpu"),
default="auto",
help="inference device (default: auto)",
)
parser.add_argument(
"--low-vram",
action="store_true",
help="reduce image tokens to lower peak VRAM usage",
)
parser.add_argument(
"--max-new-tokens",
type=int,
default=1024,
help="maximum generated tokens (default: 1024)",
)
return parser.parse_args()
def main() -> int:
args = _parse_args()
if args.max_new_tokens < 1:
raise ValueError("--max-new-tokens must be greater than zero")
runtime, dtype = _select_runtime(args.device)
if runtime == "cuda":
hardware = torch.cuda.get_device_name(0)
print(f"Loading Anime_Image2Prompt on {hardware} ({dtype})...", file=sys.stderr)
else:
print("Loading Anime_Image2Prompt on CPU (this may be slow)...", file=sys.stderr)
tagger = AnimeImage2Prompt(device=args.device, low_vram=args.low_vram)
print("Generating prompt...", file=sys.stderr)
result = tagger.predict(args.image, max_new_tokens=args.max_new_tokens)
if args.format == "json":
rendered = json.dumps(result, ensure_ascii=False, indent=2) + "\n"
else:
rendered = tagger.to_prompt(result) + "\n"
if args.output:
output_path = args.output.expanduser().resolve()
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(rendered, encoding="utf-8", newline="\n")
print(f"Saved: {output_path}", file=sys.stderr)
print(rendered, end="")
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except (FileNotFoundError, RuntimeError, ValueError) as error:
print(f"Error: {error}", file=sys.stderr)
raise SystemExit(1) from error