Spaces:
Running
Running
| import json | |
| import os | |
| import random | |
| DATA_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "tag_pools") | |
| CATEGORY_FILES = { | |
| "quality": "quality.json", | |
| "lighting": "lighting.json", | |
| "composition": "composition.json", | |
| "effects": "effects.json", | |
| "atmosphere": "atmosphere.json", | |
| "framing": "framing.json", | |
| "style": "style.json", | |
| "colors": "color_grading.json", | |
| "background": "background.json", | |
| "pose": "pose.json", | |
| "expression": "expression.json", | |
| "clothing": "clothing.json", | |
| "special_fx": "special_fx.json", | |
| "nsfw": "nsfw.json", | |
| "year_meta": "year_meta.json", | |
| "animal": "animal.json", | |
| "furry": "furry.json", | |
| "object": "object.json", | |
| "food": "food.json", | |
| "vehicle": "vehicle.json", | |
| "weapon": "weapon.json", | |
| "architecture": "architecture.json", | |
| "demon": "demon.json", | |
| "angelic": "angelic.json", | |
| "hair": "hair.json", | |
| "eyes": "eyes.json", | |
| "body": "body.json", | |
| "accessory": "accessory.json", | |
| "season": "season.json", | |
| } | |
| DISPLAY_NAMES = { | |
| "quality": ("Quality", "Качество"), | |
| "lighting": ("Lighting", "Освещение"), | |
| "composition": ("Composition", "Композиция"), | |
| "effects": ("Visual Effects", "Эффекты"), | |
| "atmosphere": ("Atmosphere", "Атмосфера"), | |
| "framing": ("Framing", "Кадрирование"), | |
| "style": ("Style", "Стиль"), | |
| "colors": ("Color Grading", "Цвета"), | |
| "background": ("Background", "Фон"), | |
| "pose": ("Pose", "Поза"), | |
| "expression": ("Expression", "Выражение"), | |
| "clothing": ("Clothing", "Одежда"), | |
| "special_fx": ("Special FX", "Спецэффекты"), | |
| "nsfw": ("NSFW Tags", "NSFW теги"), | |
| "year_meta": ("Year / Meta", "Год / Мета"), | |
| "animal": ("Animals", "Животные"), | |
| "furry": ("Furry", "Фурри"), | |
| "object": ("Objects", "Предметы"), | |
| "food": ("Food", "Еда"), | |
| "vehicle": ("Vehicles", "Транспорт"), | |
| "weapon": ("Weapons", "Оружие"), | |
| "architecture": ("Architecture", "Архитектура"), | |
| "demon": ("Demon", "Демон"), | |
| "angelic": ("Angelic", "Ангельское"), | |
| "hair": ("Hair", "Волосы"), | |
| "body": ("Body", "Тело"), | |
| "accessory": ("Accessory", "Аксессуары"), | |
| "season": ("Season", "Сезон"), | |
| } | |
| STYLE_ICONS = { | |
| "vibrant": "🎨", | |
| "dark": "🌑", | |
| "detailed": "🔍", | |
| "cute": "🌸", | |
| "elegant": "💎", | |
| "dynamic": "⚡", | |
| "moe": "💕", | |
| "sexy": "🔥", | |
| "realistic": "📷", | |
| "painterly": "🖌️", | |
| "minimalist": "⬜", | |
| } | |
| from src.safety_filter import TAG_RATING_ORDER as RATING_ORDER | |
| from src.safety_filter import ui_rating_to_tag_ceiling | |
| # Backwards-compatible alias: MAX_RATING_MAP is derived from the canonical map. | |
| MAX_RATING_MAP = { | |
| ui: ui_rating_to_tag_ceiling(ui) for ui in ("pg", "pg13", "pg16", "r", "r+") | |
| } | |
| class TagPool: | |
| def __init__(self, tags: list[dict], category: str): | |
| self.category = category | |
| self._all_tags = tags | |
| self._by_rating: dict[str, list[dict]] = {} | |
| for tag in tags: | |
| rating = tag.get("rating", "sfw") | |
| if rating not in self._by_rating: | |
| self._by_rating[rating] = [] | |
| self._by_rating[rating].append(tag) | |
| self._all_tags_cache: dict[str, list[str]] = {} | |
| def get_all_tag_dicts(self) -> list[dict]: | |
| return list(self._all_tags) | |
| def get_random(self, count: int = 1, max_rating: str = "sfw") -> list[str]: | |
| max_level = RATING_ORDER.get(max_rating, 0) | |
| available = [ | |
| t for r, tags in self._by_rating.items() | |
| if RATING_ORDER.get(r, 0) <= max_level | |
| for t in tags | |
| ] | |
| if not available: | |
| return [] | |
| # random.sample picks k unique items in O(k) — the old full-shuffle | |
| # approach was O(n) for every call (and broke rating caching). | |
| return [t["tag"] for t in random.sample(available, k=min(count, len(available)))] | |
| def get_all_tags(self, max_rating: str = "sfw") -> list[str]: | |
| cached = self._all_tags_cache.get(max_rating) | |
| if cached is not None: | |
| return list(cached) | |
| max_level = RATING_ORDER.get(max_rating, 0) | |
| result = [ | |
| t["tag"] for r, tags in self._by_rating.items() | |
| if RATING_ORDER.get(r, 0) <= max_level | |
| for t in tags | |
| ] | |
| self._all_tags_cache[max_rating] = result | |
| return list(result) | |
| class ArtistPool: | |
| def __init__(self, artists: list[dict], tandems: list[dict] | None = None): | |
| self._artists = artists | |
| self._tandems = tandems or [] | |
| # O(1) name lookup — 1000 artists make the previous linear scan wasteful. | |
| self._by_name: dict[str, dict] = {a["tag"].lower(): a for a in artists} | |
| def get_all(self) -> list[dict]: | |
| return sorted(self._artists, key=lambda a: a.get("popularity", 0), reverse=True) | |
| def get_by_style(self, style: str) -> list[dict]: | |
| return [a for a in self._artists if a.get("style") == style] | |
| def get_all_styles(self) -> list[str]: | |
| styles = set() | |
| for a in self._artists: | |
| s = a.get("style", "") | |
| if s: | |
| styles.add(s) | |
| return sorted(styles) | |
| def find_artist(self, name: str) -> dict | None: | |
| return self._by_name.get(name.lower().strip()) | |
| def suggest_artists(self, name: str, limit: int = 3) -> list[str]: | |
| """Close-match suggestions for a misspelled artist name ('did you mean…').""" | |
| import difflib | |
| names = list(self._by_name) | |
| close = difflib.get_close_matches(name.lower().strip(), names, n=limit, cutoff=0.75) | |
| return [self._by_name[c]["tag"] for c in close] | |
| def get_signature_tags(self, artist_name: str) -> list[str]: | |
| artist = self.find_artist(artist_name) | |
| if artist: | |
| return artist.get("signature_tags", []) | |
| return [] | |
| def get_tandems(self) -> list[dict]: | |
| return self._tandems | |
| def get_tandems_for_artist(self, artist_name: str) -> list[dict]: | |
| name_lower = artist_name.lower() | |
| return [ | |
| t for t in self._tandems | |
| if name_lower in [a.lower() for a in t.get("artists", [])] | |
| ] | |
| def get_description(self, artist_name: str, lang: str = "en") -> str: | |
| artist = self.find_artist(artist_name) | |
| if not artist: | |
| return "" | |
| key = f"description_{lang}" | |
| return artist.get(key, artist.get("description_en", "")) | |
| def get_danbooru_url(self, artist_name: str) -> str: | |
| artist = self.find_artist(artist_name) | |
| if not artist: | |
| return "" | |
| return artist.get("danbooru_url", "") | |
| def get_similar(self, artist_name: str, limit: int = 3) -> list[dict]: | |
| target = self.find_artist(artist_name) | |
| if not target: | |
| return [] | |
| target_style = target.get("style", "") | |
| target_tags = set(t.lower() for t in target.get("signature_tags", [])) | |
| scores = [] | |
| for a in self._artists: | |
| if a["tag"].lower() == artist_name.lower(): | |
| continue | |
| score = 0 | |
| if a.get("style") == target_style: | |
| score += 2 | |
| a_tags = set(t.lower() for t in a.get("signature_tags", [])) | |
| score += len(target_tags & a_tags) | |
| if score > 0: | |
| scores.append((score, a)) | |
| scores.sort(key=lambda x: (-x[0], -x[1].get("popularity", 0))) | |
| return [a for _, a in scores[:limit]] | |
| class TagWarehouse: | |
| def __init__(self): | |
| self.pools: dict[str, TagPool] = {} | |
| self.artist_pool: ArtistPool | None = None | |
| self._conflict_map: dict[str, set[str]] = {} | |
| self._tag_rating_index: dict[str, int] = {} | |
| self._load_pools() | |
| self._load_artists() | |
| def _load_pools(self): | |
| for category, filename in CATEGORY_FILES.items(): | |
| filepath = os.path.join(DATA_DIR, filename) | |
| if not os.path.exists(filepath): | |
| continue | |
| try: | |
| with open(filepath, "r", encoding="utf-8") as f: | |
| tags = json.load(f) | |
| except (json.JSONDecodeError, OSError): | |
| continue | |
| if not isinstance(tags, list): | |
| continue | |
| self.pools[category] = TagPool(tags, category) | |
| for tag in tags: | |
| if not isinstance(tag, dict) or "tag" not in tag: | |
| continue | |
| name = tag["tag"].lower().strip() | |
| level = RATING_ORDER.get(tag.get("rating", "sfw"), 0) | |
| prev = self._tag_rating_index.get(name) | |
| if prev is None or level > prev: | |
| self._tag_rating_index[name] = level | |
| conflicts = tag.get("conflicts", []) | |
| if name not in self._conflict_map: | |
| self._conflict_map[name] = set() | |
| if isinstance(conflicts, list): | |
| for c in conflicts: | |
| cl = c.lower().strip() | |
| self._conflict_map[name].add(cl) | |
| if cl not in self._conflict_map: | |
| self._conflict_map[cl] = set() | |
| self._conflict_map[cl].add(name) | |
| def reload_pools(self): | |
| self.pools.clear() | |
| self._conflict_map.clear() | |
| self._tag_rating_index.clear() | |
| self._load_pools() | |
| def tag_exceeds_rating(self, tag: str, max_rating: str) -> bool: | |
| """True if a tag is only available above the requested max rating (used to | |
| gate artist signature tags so they don't leak NSFW content at safe ratings).""" | |
| level = self._tag_rating_index.get(tag.lower().strip()) | |
| if level is None: | |
| return False | |
| return level > RATING_ORDER.get(max_rating, 0) | |
| def reload_artists(self): | |
| self.artist_pool = None | |
| self._load_artists() | |
| def _load_artists(self): | |
| filepath = os.path.join(DATA_DIR, "artist.json") | |
| if not os.path.exists(filepath): | |
| return | |
| try: | |
| with open(filepath, "r", encoding="utf-8") as f: | |
| data = json.load(f) | |
| except (json.JSONDecodeError, OSError): | |
| return | |
| if isinstance(data, dict): | |
| artists = data.get("artists", []) | |
| tandems = data.get("tandems", []) | |
| else: | |
| artists = data | |
| tandems = [] | |
| self.artist_pool = ArtistPool(artists, tandems) | |
| def get_pool(self, category: str) -> TagPool | None: | |
| return self.pools.get(category) | |
| def get_all_artists(self) -> list[dict]: | |
| if self.artist_pool: | |
| return self.artist_pool.get_all() | |
| return [] | |
| def get_artist_styles(self) -> list[str]: | |
| if self.artist_pool: | |
| return self.artist_pool.get_all_styles() | |
| return [] | |
| def get_artists_by_style(self, style: str) -> list[dict]: | |
| if self.artist_pool: | |
| return self.artist_pool.get_by_style(style) | |
| return [] | |
| def get_artist_signature_tags(self, artist_name: str) -> list[str]: | |
| if self.artist_pool: | |
| return self.artist_pool.get_signature_tags(artist_name) | |
| return [] | |
| def get_all_tandems(self) -> list[dict]: | |
| if self.artist_pool: | |
| return self.artist_pool.get_tandems() | |
| return [] | |
| def get_tandems_for_artist(self, artist_name: str) -> list[dict]: | |
| if self.artist_pool: | |
| return self.artist_pool.get_tandems_for_artist(artist_name) | |
| return [] | |
| def get_artist_description(self, artist_name: str, lang: str = "en") -> str: | |
| if self.artist_pool: | |
| return self.artist_pool.get_description(artist_name, lang) | |
| return "" | |
| def get_artist_danbooru_url(self, artist_name: str) -> str: | |
| if self.artist_pool: | |
| return self.artist_pool.get_danbooru_url(artist_name) | |
| return "" | |
| def get_similar_artists(self, artist_name: str, limit: int = 3) -> list[dict]: | |
| if self.artist_pool: | |
| return self.artist_pool.get_similar(artist_name, limit) | |
| return [] | |
| def get_category_display_name(self, category: str, lang: str = "en") -> str: | |
| names = DISPLAY_NAMES.get(category, (category, category)) | |
| return names[1] if lang == "ru" else names[0] | |
| def get_style_icon(self, style: str) -> str: | |
| return STYLE_ICONS.get(style, "") | |
| def has_conflict(self, tag: str, selected_tags: list[str]) -> bool: | |
| conflicts = self._conflict_map.get(tag.lower().strip(), set()) | |
| for st in selected_tags: | |
| if st.lower().strip() in conflicts: | |
| return True | |
| return False | |
| def get_available_categories(self, rating: str = "pg") -> list[str]: | |
| max_level = RATING_ORDER.get(MAX_RATING_MAP.get(rating, "sfw"), 0) | |
| available = [] | |
| for cat, pool in self.pools.items(): | |
| if pool.get_all_tags(max_rating=MAX_RATING_MAP.get(rating, "sfw")): | |
| available.append(cat) | |
| return available | |