feat: ML tag suggestions, character/fandom integrity, underscores, modal polish
Consolidated merge of feat/tag-suggestions branch. Original 64-commit history was lost to git-object corruption in a Nextcloud-synced checkout; this single commit captures the equivalent diff. Includes: - pgvector-backed tag suggestion infra (WD14 + SigLIP centroids, ml-worker container, Celery tasks, suggestion service, accept/reject endpoints + modal UI with green/red chip buttons) - Character/fandom integrity: title-case normalization on every write path, fandom-id backfill, maintenance task + settings button, migrations g26041901 + h26041901 to canonicalize legacy rows with case-only duplicate merging - Tag-underscores + modal polish: WD14 name canonicalization at emit + accept + add/bulk-add paths, migration i26041901 for legacy-row rename-or-merge across character/fandom/NULL kinds, suggestion-accept refresh parity via awaited loadTags, persistent chip tint
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Machine-learning inference wrappers used by the ml-worker."""
|
||||
@@ -0,0 +1,58 @@
|
||||
"""SigLIP SO400M image-embedding wrapper (PyTorch CPU)."""
|
||||
from __future__ import annotations
|
||||
import os
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
# Defer torch/transformers imports to lazily-loaded functions to allow
|
||||
# importing MODEL_VERSION in non-ML-worker contexts (e.g., web container
|
||||
# centroid-recompute enqueue logic).
|
||||
_torch = None
|
||||
_AutoModel = None
|
||||
_AutoProcessor = None
|
||||
|
||||
MODEL_NAME = os.environ.get('SIGLIP_MODEL_NAME', 'google/siglip-so400m-patch14-384')
|
||||
MODEL_VERSION = os.environ.get('SIGLIP_MODEL_VERSION', 'siglip-so400m-patch14-384')
|
||||
# Model files live flat under this directory (written by scripts/download_models.py via
|
||||
# snapshot_download(local_dir=...)). We point from_pretrained at the local path directly
|
||||
# so transformers bypasses its HF cache layout and doesn't need network access at load time.
|
||||
_LOCAL_DIR = os.path.join(os.environ.get('ML_MODEL_DIR', '/models'), 'siglip')
|
||||
|
||||
_model = None
|
||||
_processor = None
|
||||
|
||||
|
||||
# NOT thread-safe. Must run in the ml-worker container with --concurrency=1.
|
||||
def _load() -> None:
|
||||
global _model, _processor, _torch, _AutoModel, _AutoProcessor
|
||||
if _model is not None:
|
||||
return
|
||||
# Lazy import torch/transformers so this module can be imported in contexts
|
||||
# where they're not available (e.g., web container for centroid-recompute enqueue).
|
||||
import torch
|
||||
from transformers import AutoModel, AutoProcessor
|
||||
_torch = torch
|
||||
_AutoModel = AutoModel
|
||||
_AutoProcessor = AutoProcessor
|
||||
|
||||
_processor = _AutoProcessor.from_pretrained(_LOCAL_DIR)
|
||||
_model = _AutoModel.from_pretrained(_LOCAL_DIR)
|
||||
_model.eval()
|
||||
|
||||
|
||||
def infer(image_path: str) -> np.ndarray:
|
||||
"""Return a 1152-dim float32 numpy embedding for the image.
|
||||
|
||||
SigLIP uses a MAP-pooled vision head — the pooled output is the retrieval-ready
|
||||
embedding the model was trained to produce. `get_image_features` on transformers
|
||||
>= 4.45 returns a BaseModelOutputWithPooling, so pull `.pooler_output` explicitly
|
||||
rather than relying on the first-field fallback from indexing.
|
||||
"""
|
||||
_load()
|
||||
img = Image.open(image_path).convert('RGB')
|
||||
with _torch.no_grad():
|
||||
inputs = _processor(images=img, return_tensors='pt')
|
||||
out = _model.get_image_features(**inputs)
|
||||
pooled = out.pooler_output if hasattr(out, 'pooler_output') else out # (1, 1152)
|
||||
return pooled[0].numpy().astype(np.float32)
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
"""WD14 EVA02-Large tagger (ONNX CPU inference)."""
|
||||
from __future__ import annotations
|
||||
import csv
|
||||
import os
|
||||
from typing import Iterable
|
||||
|
||||
import numpy as np
|
||||
import onnxruntime as ort
|
||||
from PIL import Image
|
||||
|
||||
MODEL_VERSION = os.environ.get('WD14_MODEL_VERSION', 'wd-eva02-large-tagger-v3')
|
||||
_MODEL_DIR = os.environ.get('ML_MODEL_DIR', '/models')
|
||||
_WD14_DIR = os.path.join(_MODEL_DIR, 'wd14')
|
||||
_MODEL_PATH = os.path.join(_WD14_DIR, 'model.onnx')
|
||||
_TAGS_PATH = os.path.join(_WD14_DIR, 'selected_tags.csv')
|
||||
|
||||
# WD14 selected_tags.csv uses Danbooru category ids:
|
||||
# 0=general, 1=artist, 3=copyright, 4=character, 5=meta, 9=rating
|
||||
_CATEGORY_MAP = {0: 'general', 1: 'artist', 3: 'copyright', 4: 'character', 5: 'meta', 9: 'rating'}
|
||||
|
||||
_session: ort.InferenceSession | None = None
|
||||
_tag_meta: list[dict] | None = None
|
||||
_input_name: str | None = None
|
||||
_output_name: str | None = None
|
||||
_input_size: int = 448
|
||||
|
||||
|
||||
# NOT thread-safe. Must run in the ml-worker container with --concurrency=1.
|
||||
def _load() -> None:
|
||||
global _session, _tag_meta, _input_name, _output_name, _input_size
|
||||
if _session is not None:
|
||||
return
|
||||
|
||||
if not os.path.isfile(_MODEL_PATH):
|
||||
raise RuntimeError(
|
||||
f"WD14 model file missing at {_MODEL_PATH}. "
|
||||
f"Populate the /models volume via the ml-worker downloader."
|
||||
)
|
||||
if not os.path.isfile(_TAGS_PATH):
|
||||
raise RuntimeError(
|
||||
f"WD14 selected_tags.csv missing at {_TAGS_PATH}. "
|
||||
f"Populate the /models volume via the ml-worker downloader."
|
||||
)
|
||||
|
||||
tag_meta: list[dict] = []
|
||||
with open(_TAGS_PATH, newline='') as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
tag_meta.append({
|
||||
'name': row['name'],
|
||||
'category': _CATEGORY_MAP.get(int(row['category']), 'unknown'),
|
||||
})
|
||||
|
||||
session = ort.InferenceSession(
|
||||
_MODEL_PATH,
|
||||
providers=['CPUExecutionProvider'],
|
||||
)
|
||||
_input_name = session.get_inputs()[0].name
|
||||
_output_name = session.get_outputs()[0].name
|
||||
# Input shape is usually [batch, H, W, 3] NHWC; pick the spatial dim
|
||||
input_shape = session.get_inputs()[0].shape
|
||||
for dim in input_shape:
|
||||
if isinstance(dim, int) and dim > 1:
|
||||
_input_size = dim
|
||||
break
|
||||
# Assign sentinels last so a partially-loaded state can't be observed.
|
||||
_tag_meta = tag_meta
|
||||
_session = session
|
||||
|
||||
|
||||
def _preprocess(image_path: str) -> np.ndarray:
|
||||
img = Image.open(image_path)
|
||||
if img.mode != 'RGBA':
|
||||
img = img.convert('RGBA')
|
||||
# Composite onto white background so transparency doesn't bias the model
|
||||
bg = Image.new('RGBA', img.size, (255, 255, 255, 255))
|
||||
bg.paste(img, mask=img.split()[3] if img.mode == 'RGBA' else None)
|
||||
img = bg.convert('RGB')
|
||||
|
||||
w, h = img.size
|
||||
side = max(w, h)
|
||||
square = Image.new('RGB', (side, side), (255, 255, 255))
|
||||
square.paste(img, ((side - w) // 2, (side - h) // 2))
|
||||
square = square.resize((_input_size, _input_size), Image.BICUBIC)
|
||||
|
||||
arr = np.array(square, dtype=np.float32)
|
||||
# WD14 was trained on BGR
|
||||
arr = arr[:, :, ::-1]
|
||||
return arr[np.newaxis, :, :, :] # NHWC
|
||||
|
||||
|
||||
def infer(image_path: str) -> list[dict]:
|
||||
"""Run WD14 on one image. Returns a list of {name, category, confidence}."""
|
||||
_load()
|
||||
x = _preprocess(image_path)
|
||||
out = _session.run([_output_name], {_input_name: x})[0][0]
|
||||
results: list[dict] = []
|
||||
for idx, score in enumerate(out):
|
||||
meta = _tag_meta[idx]
|
||||
results.append({
|
||||
'name': meta['name'],
|
||||
'category': meta['category'],
|
||||
'confidence': float(score),
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
def infer_filtered(image_path: str, min_any: float = 0.05) -> list[dict]:
|
||||
"""Same as infer() but drops tags below a floor to keep DB rows reasonable."""
|
||||
return [r for r in infer(image_path) if r['confidence'] >= min_any]
|
||||
Reference in New Issue
Block a user