From 36b2ed6bb7975b3826817c3ced0062eeddeec802 Mon Sep 17 00:00:00 2001 From: CalamitousFelicitousness Date: Tue, 24 Mar 2026 23:41:06 +0000 Subject: [PATCH] feat(dicts): add curated dicts, pruning, and manifest generation Add 4 curated vocabulary dicts (art, photography, quality, negative) that ship bundled for out-of-the-box autocomplete. Add manifest.json listing all dicts available on HuggingFace. CLI tools: - cli/prune_dicts.py: per-category pruning with configurable thresholds - cli/gen_manifest.py: generate manifest from on-disk dict files - cli/fetch_dicts.py: unified 14-category scheme, idol source, auto manifest regeneration after fetch Fetch and prune auto-update manifest.json when one already exists. --- cli/fetch_dicts.py | 299 ++++++++++++++++++++++++------------ cli/gen_manifest.py | 114 ++++++++++++++ cli/prune_dicts.py | 137 +++++++++++++++++ data/dicts/art.json | 1 + data/dicts/manifest.json | 67 ++++++++ data/dicts/negative.json | 182 ++++++++++++++++++++++ data/dicts/photography.json | 1 + data/dicts/quality.json | 135 ++++++++++++++++ 8 files changed, 837 insertions(+), 99 deletions(-) create mode 100644 cli/gen_manifest.py create mode 100644 cli/prune_dicts.py create mode 100644 data/dicts/art.json create mode 100644 data/dicts/manifest.json create mode 100644 data/dicts/negative.json create mode 100644 data/dicts/photography.json create mode 100644 data/dicts/quality.json diff --git a/cli/fetch_dicts.py b/cli/fetch_dicts.py index 0c1af99df..a0dea8ecb 100644 --- a/cli/fetch_dicts.py +++ b/cli/fetch_dicts.py @@ -6,8 +6,12 @@ Usage: python cli/fetch_dicts.py e621 [--output PATH] [--min-count N] python cli/fetch_dicts.py rule34 --key USER_ID:API_KEY [--output PATH] [--min-count N] python cli/fetch_dicts.py sankaku [--output PATH] [--min-count N] + python cli/fetch_dicts.py idol [--output PATH] [--min-count N] python cli/fetch_dicts.py all --key USER_ID:API_KEY [--output-dir DIR] [--min-count N] +Fetches are checkpointed every 50 pages to a .partial file so interrupted +runs can be resumed. Transient HTTP errors are retried with backoff. + Output format: JSON with { name, version, categories, tags: [[name, category_id, post_count], ...] } Tags sorted by post_count descending. @@ -22,59 +26,115 @@ from datetime import date import requests -DANBOORU_CATEGORIES = { +# Unified category scheme - all sources map their native type IDs to these. +# Every dict file uses these same IDs and colors. +UNIFIED_CATEGORIES = { "0": {"name": "general", "color": "#0075f8"}, - "1": {"name": "artist", "color": "#a800aa"}, - "3": {"name": "copyright", "color": "#dd00dd"}, - "4": {"name": "character", "color": "#00ab2c"}, - "5": {"name": "meta", "color": "#ee8800"}, -} - -E621_CATEGORIES = { - "0": {"name": "general", "color": "#0075f8"}, - "1": {"name": "artist", "color": "#a800aa"}, - "3": {"name": "copyright", "color": "#dd00dd"}, + "1": {"name": "artist", "color": "#cc0000"}, + "2": {"name": "studio", "color": "#ff4500"}, + "3": {"name": "copyright", "color": "#9900ff"}, "4": {"name": "character", "color": "#00ab2c"}, "5": {"name": "species", "color": "#ed5d1f"}, - "6": {"name": "invalid", "color": "#ff3d3d"}, - "7": {"name": "meta", "color": "#ee8800"}, - "8": {"name": "lore", "color": "#228b22"}, + "6": {"name": "genre", "color": "#8a66ff"}, + "7": {"name": "medium", "color": "#00cccc"}, + "8": {"name": "meta", "color": "#6b7280"}, + "9": {"name": "lore", "color": "#228b22"}, + "10": {"name": "lens", "color": "#e67e22"}, + "11": {"name": "lighting", "color": "#f1c40f"}, + "12": {"name": "composition", "color": "#1abc9c"}, + "13": {"name": "color", "color": "#e84393"}, } -# Rule34 uses the same category IDs as Danbooru (Gelbooru-compatible) -RULE34_CATEGORIES = { - "0": {"name": "general", "color": "#0075f8"}, - "1": {"name": "artist", "color": "#a800aa"}, - "3": {"name": "copyright", "color": "#dd00dd"}, - "4": {"name": "character", "color": "#00ab2c"}, - "5": {"name": "meta", "color": "#ee8800"}, -} +# Source → unified type maps. Each maps the source's native category IDs +# to the unified IDs above. Unmapped IDs default to 0 (general). +DANBOORU_TYPE_MAP = {0: 0, 1: 1, 3: 3, 4: 4, 5: 8} +E621_TYPE_MAP = {0: 0, 1: 1, 2: 2, 3: 3, 4: 4, 5: 5, 6: 0, 7: 8, 8: 9} +RULE34_TYPE_MAP = {0: 0, 1: 1, 3: 3, 4: 4, 5: 8} +SANKAKU_TYPE_MAP = {0: 0, 1: 1, 2: 2, 3: 3, 4: 4, 5: 6, 8: 7, 9: 8} -SANKAKU_CATEGORIES = { - "0": {"name": "general", "color": "#0075f8"}, - "1": {"name": "artist", "color": "#a800aa"}, - "2": {"name": "studio", "color": "#a800aa"}, - "3": {"name": "copyright", "color": "#dd00dd"}, - "4": {"name": "character", "color": "#00ab2c"}, - "5": {"name": "species", "color": "#ed5d1f"}, - "8": {"name": "medium", "color": "#ee8800"}, - "9": {"name": "meta", "color": "#ee8800"}, +# Idol Complex uses Sankaku's granular tag type system (21 subcategories). +# API type → unified category: +# 0=studio, 1=artist, 2=studio, 3=franchise, 4=character, +# 5=photoset, 6=genre, 8=medium, 9=meta, 10=fashion, +# 11=anatomy, 12=pose, 13=activity, 14=role, 15=flora, +# 17=fauna/entity, 18=object/setting, 19=substance, 20=general, +# 21=language, 22=automatic +IDOL_TYPE_MAP = { + 0: 2, 1: 1, 2: 2, 3: 3, 4: 4, 5: 8, 6: 6, 8: 7, 9: 8, + 10: 0, 11: 0, 12: 0, 13: 0, 14: 0, 15: 0, 17: 0, 18: 0, + 19: 0, 20: 0, 21: 8, 22: 8, } USER_AGENT = "SDNext-DictFetcher/1.0 (tag autocomplete)" +CHECKPOINT_INTERVAL = 50 # save progress every N pages +MAX_RETRIES = 3 +RETRY_BACKOFF = 5 # seconds, multiplied by attempt number -def fetch_danbooru(min_count: int = 10, **_kwargs) -> list: +# ── Checkpoint helpers ── + +def save_checkpoint(path: str, page: int, tags: list): + """Save fetch progress to a .partial file.""" + if not path: + return + tmp = path + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump({"page": page, "tags": tags}, f, separators=(",", ":")) + os.replace(tmp, path) + + +def load_checkpoint(path: str) -> tuple[int, list] | tuple[None, list]: + """Load fetch progress from a .partial file. Returns (page, tags) or (None, []).""" + if not path or not os.path.isfile(path): + return None, [] + try: + with open(path, encoding="utf-8") as f: + data = json.load(f) + page = data["page"] + tags = data["tags"] + print(f" Resuming from checkpoint: page {page}, {len(tags)} tags", file=sys.stderr) + return page, tags + except (json.JSONDecodeError, KeyError) as e: + print(f" Warning: corrupt checkpoint, starting fresh ({e})", file=sys.stderr) + return None, [] + + +def clear_checkpoint(path: str): + """Remove checkpoint file after successful completion.""" + if path and os.path.isfile(path): + os.remove(path) + + +# ── HTTP retry helper ── + +def fetch_with_retry(session: requests.Session, url: str, params: dict | None = None, timeout: int = 30) -> requests.Response: + """GET with retry and exponential backoff for transient errors.""" + for attempt in range(MAX_RETRIES): + try: + resp = session.get(url, params=params, timeout=timeout) + resp.raise_for_status() + return resp + except requests.RequestException as e: + if attempt < MAX_RETRIES - 1: + wait = RETRY_BACKOFF * (attempt + 1) + print(f" Retry {attempt + 1}/{MAX_RETRIES} in {wait}s: {e}", file=sys.stderr) + time.sleep(wait) + else: + raise + + +# ── Fetchers ── + +def fetch_danbooru(min_count: int = 10, checkpoint_path: str = "", **_kwargs) -> list: """Fetch tags from Danbooru API, paginated.""" - tags = [] - page = 1 + start_page, tags = load_checkpoint(checkpoint_path) + page = (start_page + 1) if start_page is not None else 1 session = requests.Session() session.headers["User-Agent"] = USER_AGENT while True: url = f"https://danbooru.donmai.us/tags.json?limit=1000&page={page}&search[order]=count" try: - resp = session.get(url, timeout=30) - resp.raise_for_status() + resp = fetch_with_retry(session, url) except requests.RequestException as e: print(f" Error on page {page}: {e}", file=sys.stderr) break @@ -90,23 +150,24 @@ def fetch_danbooru(min_count: int = 10, **_kwargs) -> list: print(f" Page {page}: {len(data)} tags (total: {len(tags)})", file=sys.stderr) if below_threshold: break + if page % CHECKPOINT_INTERVAL == 0: + save_checkpoint(checkpoint_path, page, tags) page += 1 time.sleep(0.5) tags.sort(key=lambda t: t[2], reverse=True) return tags -def fetch_e621(min_count: int = 10, **_kwargs) -> list: +def fetch_e621(min_count: int = 10, checkpoint_path: str = "", **_kwargs) -> list: """Fetch tags from e621 API, paginated.""" - tags = [] - page = 1 + start_page, tags = load_checkpoint(checkpoint_path) + page = (start_page + 1) if start_page is not None else 1 session = requests.Session() session.headers["User-Agent"] = USER_AGENT while True: url = f"https://e621.net/tags.json?limit=320&page={page}&search[order]=count" try: - resp = session.get(url, timeout=30) - resp.raise_for_status() + resp = fetch_with_retry(session, url) except requests.RequestException as e: print(f" Error on page {page}: {e}", file=sys.stderr) break @@ -122,13 +183,15 @@ def fetch_e621(min_count: int = 10, **_kwargs) -> list: print(f" Page {page}: {len(data)} tags (total: {len(tags)})", file=sys.stderr) if below_threshold: break + if page % CHECKPOINT_INTERVAL == 0: + save_checkpoint(checkpoint_path, page, tags) page += 1 - time.sleep(1.0) # e621 rate limit is stricter + time.sleep(1.0) tags.sort(key=lambda t: t[2], reverse=True) return tags -def fetch_gelbooru(base_url: str, min_count: int = 10, api_key: str | None = None, rate_limit: float = 0.5) -> list: +def fetch_gelbooru(base_url: str, min_count: int = 10, api_key: str | None = None, rate_limit: float = 0.5, checkpoint_path: str = "") -> list: """Fetch tags from a Gelbooru-compatible API (rule34, gelbooru, etc.). Unlike Danbooru/e621, the Gelbooru tag endpoint doesn't support ordering @@ -137,8 +200,8 @@ def fetch_gelbooru(base_url: str, min_count: int = 10, api_key: str | None = Non """ import xml.etree.ElementTree as ET - tags = [] - page = 0 + start_page, tags = load_checkpoint(checkpoint_path) + page = (start_page + 1) if start_page is not None else 0 page_size = 1000 session = requests.Session() session.headers["User-Agent"] = USER_AGENT @@ -157,18 +220,22 @@ def fetch_gelbooru(base_url: str, min_count: int = 10, api_key: str | None = Non while True: params["pid"] = str(page) try: - resp = session.get(base_url, params=params, timeout=30) - resp.raise_for_status() + resp = fetch_with_retry(session, base_url, params=params) except requests.RequestException as e: - print(f" Error on page {page}: {e}", file=sys.stderr) + print(f" Error on page {page} after {MAX_RETRIES} retries: {e}", file=sys.stderr) break text = resp.text.strip() if not text or text.startswith('"') or not text.startswith(' list: +def fetch_rule34(min_count: int = 10, api_key: str | None = None, checkpoint_path: str = "", **_kwargs) -> list: """Fetch tags from rule34.xxx.""" if not api_key: print(" Warning: no --key provided, rule34 may rate-limit aggressively", file=sys.stderr) - return fetch_gelbooru("https://api.rule34.xxx/index.php", min_count=min_count, api_key=api_key) + return fetch_gelbooru("https://api.rule34.xxx/index.php", min_count=min_count, api_key=api_key, checkpoint_path=checkpoint_path) -def fetch_sankaku(min_count: int = 10, **_kwargs) -> list: +def fetch_sankaku(min_count: int = 10, checkpoint_path: str = "", **_kwargs) -> list: """Fetch tags from Sankaku Complex (chan.sankakucomplex.com). Uses the public JSON API at sankakuapi.com which supports order=count, so we can stop early when counts drop below min_count. - Tag names come as English with spaces — converted to lowercase underscores. + Tag names come as English with spaces - converted to lowercase. """ - tags = [] - page = 1 - page_size = 200 # API max + start_page, tags = load_checkpoint(checkpoint_path) + page = (start_page + 1) if start_page is not None else 1 + page_size = 200 session = requests.Session() session.headers["User-Agent"] = USER_AGENT while True: try: - resp = session.get( - "https://sankakuapi.com/tags", - params={"limit": page_size, "page": page, "order": "count"}, - timeout=30, - ) - resp.raise_for_status() + resp = fetch_with_retry(session, "https://sankakuapi.com/tags", + params={"limit": page_size, "page": page, "order": "count"}) except requests.RequestException as e: - print(f" Error on page {page}: {e}", file=sys.stderr) + print(f" Error on page {page} after {MAX_RETRIES} retries: {e}", file=sys.stderr) break data = resp.json() if not data: break - below_threshold = True for tag in data: count = tag.get("post_count", 0) - if count >= min_count: - below_threshold = False - name = tag.get("name_en") or tag.get("name_ja", "") - if not name: - continue - name = name.strip().lower() - tags.append([name, tag.get("type", 0), count]) - else: - below_threshold = True + if count < min_count: + continue + name = tag.get("name_en") or tag.get("name_ja", "") + if not name: + continue + name = name.strip().lower() + tags.append([name, tag.get("type", 0), count]) + below_threshold = all(tag.get("post_count", 0) < min_count for tag in data) print(f" Page {page}: {len(data)} tags (total: {len(tags)})", file=sys.stderr) if below_threshold: break + if page % CHECKPOINT_INTERVAL == 0: + save_checkpoint(checkpoint_path, page, tags) page += 1 time.sleep(0.5) tags.sort(key=lambda t: t[2], reverse=True) return tags +def fetch_idol(min_count: int = 10, checkpoint_path: str = "", **_kwargs) -> list: + """Fetch tags from Idol Complex (idol.sankakucomplex.com). + + Uses the legacy JSON API at iapi.sankakucomplex.com. Supports order=count. + Max 50 tags per page. Type IDs are non-standard - remapped in write_dict. + """ + start_page, tags = load_checkpoint(checkpoint_path) + page = (start_page + 1) if start_page is not None else 1 + page_size = 50 + session = requests.Session() + session.headers["User-Agent"] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/138.0.0.0 Safari/537.36" + while True: + try: + resp = fetch_with_retry(session, "https://iapi.sankakucomplex.com/tags.json", + params={"limit": page_size, "page": page, "order": "count"}) + except requests.RequestException as e: + print(f" Error on page {page} after {MAX_RETRIES} retries: {e}", file=sys.stderr) + break + data = resp.json() + if not data: + break + for tag in data: + count = tag.get("count", 0) + if count < min_count: + continue + name = tag.get("name", "") + if not name: + continue + tags.append([name, tag.get("type", 0), count]) + below_threshold = all(tag.get("count", 0) < min_count for tag in data) + print(f" Page {page}: {len(data)} tags (total: {len(tags)})", file=sys.stderr) + if below_threshold: + break + if page % CHECKPOINT_INTERVAL == 0: + save_checkpoint(checkpoint_path, page, tags) + page += 1 + time.sleep(1.0) + tags.sort(key=lambda t: t[2], reverse=True) + return tags + + SOURCES = { - "danbooru": { - "fetch": fetch_danbooru, - "categories": DANBOORU_CATEGORIES, - }, - "e621": { - "fetch": fetch_e621, - "categories": E621_CATEGORIES, - }, - "rule34": { - "fetch": fetch_rule34, - "categories": RULE34_CATEGORIES, - }, - "sankaku": { - "fetch": fetch_sankaku, - "categories": SANKAKU_CATEGORIES, - }, + "danbooru": {"fetch": fetch_danbooru, "type_map": DANBOORU_TYPE_MAP}, + "e621": {"fetch": fetch_e621, "type_map": E621_TYPE_MAP}, + "rule34": {"fetch": fetch_rule34, "type_map": RULE34_TYPE_MAP}, + "sankaku": {"fetch": fetch_sankaku, "type_map": SANKAKU_TYPE_MAP}, + "idol": {"fetch": fetch_idol, "type_map": IDOL_TYPE_MAP}, } -def write_dict(name: str, tags: list, categories: dict, output_path: str, separator: str = "_"): +def write_dict(name: str, tags: list, type_map: dict, output_path: str, separator: str = "_"): """Write dict JSON file atomically. + type_map remaps source-native category IDs to unified IDs. separator controls the word separator in tag names: "_" (default) → "high_resolution" (booru convention, anime/illustration models) " " → "high resolution" (natural language, SDXL/Flux-style models) """ - if separator == "_": - tags = [[t[0].replace(" ", "_"), t[1], t[2]] for t in tags] - else: - tags = [[t[0].replace("_", " "), t[1], t[2]] for t in tags] + normalized = [] + for t in tags: + tag_name = t[0].replace(" ", "_") if separator == "_" else t[0].replace("_", " ") + category = type_map.get(t[1], 0) + normalized.append([tag_name, category, t[2]]) data = { "name": name, "version": date.today().isoformat(), - "categories": categories, - "tags": tags, + "categories": UNIFIED_CATEGORIES, + "tags": normalized, } os.makedirs(os.path.dirname(output_path) or '.', exist_ok=True) tmp_path = output_path + ".tmp" @@ -289,12 +386,16 @@ def fetch_source(name: str, output: str, min_count: int, api_key: str | None = N print(f"Unknown source: {name}. Available: {', '.join(SOURCES.keys())}", file=sys.stderr) sys.exit(1) source = SOURCES[name] + checkpoint_path = output + ".partial" print(f"Fetching {name} (min_count={min_count})...", file=sys.stderr) - tags = source["fetch"](min_count=min_count, api_key=api_key) + tags = source["fetch"](min_count=min_count, api_key=api_key, checkpoint_path=checkpoint_path) if not tags: print(f" No tags fetched for {name}", file=sys.stderr) return - write_dict(name, tags, source["categories"], output, separator=separator) + write_dict(name, tags, source["type_map"], output, separator=separator) + clear_checkpoint(checkpoint_path) + from gen_manifest import update_manifest + update_manifest(os.path.dirname(output) or ".") def main(): diff --git a/cli/gen_manifest.py b/cli/gen_manifest.py new file mode 100644 index 000000000..56a658d02 --- /dev/null +++ b/cli/gen_manifest.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""Generate manifest.json for the HuggingFace dict repository. + +Reads dict JSON files and writes a manifest with accurate tag counts, +file sizes, and versions. Only listed files are included - curated dicts +that ship bundled with the repo should not be passed as arguments. + +Usage: + python cli/gen_manifest.py data/dicts/danbooru.json data/dicts/e621.json ... + python cli/gen_manifest.py data/dicts/danbooru.json -o data/dicts/manifest.json + python cli/gen_manifest.py data/dicts/*.json --exclude art photography quality negative +""" + +import argparse +import json +import os +import sys + +# Human-readable descriptions keyed by dict name. +# Add entries here when new sources are added to fetch_dicts.py. +DESCRIPTIONS = { + "art": "Art movements, styles, and techniques", + "danbooru": "Danbooru image board tags - anime/illustration focused", + "e621": "e621 tags - furry/animal art focused", + "idol": "Idol Complex tags - Japanese idol photography", + "negative": "Common negative prompt terms", + "photography": "Photography terms - lens, lighting, composition, color", + "quality": "Quality and aesthetic meta tags", + "rule34": "Rule34.xxx tags - multi-fandom", + "sankaku": "Sankaku Complex tags - anime/illustration with granular categories", +} + + +def build_entry(filepath: str) -> dict: + """Build a manifest entry from a dict JSON file.""" + with open(filepath, encoding="utf-8") as f: + data = json.load(f) + name = data.get("name") or os.path.splitext(os.path.basename(filepath))[0] + size_mb = round(os.path.getsize(filepath) / (1024 * 1024), 1) + return { + "name": name, + "description": DESCRIPTIONS.get(name, ""), + "version": data.get("version", ""), + "tag_count": len(data.get("tags", [])), + "size_mb": size_mb, + } + + +def update_manifest(dicts_dir: str) -> bool: + """Regenerate manifest.json in dicts_dir if one already exists. + + Only updates entries for dicts already listed in the manifest - does not + add new dicts. Returns True if the manifest was updated, False if no + manifest exists to update. + """ + manifest_path = os.path.join(dicts_dir, "manifest.json") + if not os.path.isfile(manifest_path): + return False + with open(manifest_path, encoding="utf-8") as f: + manifest = json.load(f) + existing_names = {e["name"] for e in manifest.get("dicts", [])} + entries = [] + for name in sorted(existing_names): + filepath = os.path.join(dicts_dir, f"{name}.json") + if not os.path.isfile(filepath): + continue + try: + entries.append(build_entry(filepath)) + except (json.JSONDecodeError, KeyError): + continue + manifest["dicts"] = entries + with open(manifest_path, "w", encoding="utf-8") as f: + json.dump(manifest, f, indent=2, ensure_ascii=False) + f.write("\n") + print(f" Manifest updated: {manifest_path} ({len(entries)} dicts)", file=sys.stderr) + return True + + +def main(): + parser = argparse.ArgumentParser(description="Generate manifest.json for HF dict repo") + parser.add_argument("files", nargs="+", help="Dict JSON files to include") + parser.add_argument("-o", "--output", default="manifest.json", help="Output path (default: manifest.json)") + parser.add_argument("--exclude", nargs="*", default=[], help="Dict names to exclude (e.g. art quality)") + args = parser.parse_args() + + exclude = set(args.exclude) + entries = [] + for filepath in args.files: + basename = os.path.splitext(os.path.basename(filepath))[0] + if basename in exclude or basename == "manifest": + continue + if not os.path.isfile(filepath): + print(f" Skipping {filepath}: not found", file=sys.stderr) + continue + try: + entry = build_entry(filepath) + entries.append(entry) + print(f" {entry['name']:>12}: {entry['tag_count']:>10,} tags, {entry['size_mb']:>6.1f} MB, v={entry['version']}", file=sys.stderr) + except (json.JSONDecodeError, KeyError) as e: + print(f" Skipping {filepath}: {e}", file=sys.stderr) + + entries.sort(key=lambda e: e["name"]) + manifest = {"dicts": entries} + + os.makedirs(os.path.dirname(args.output) or ".", exist_ok=True) + with open(args.output, "w", encoding="utf-8") as f: + json.dump(manifest, f, indent=2, ensure_ascii=False) + f.write("\n") + + print(f"\nManifest written: {args.output} ({len(entries)} dicts)", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/cli/prune_dicts.py b/cli/prune_dicts.py new file mode 100644 index 000000000..aa04cc47e --- /dev/null +++ b/cli/prune_dicts.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +"""Prune tag dictionaries with per-category minimum post counts. + +Usage: + python cli/prune_dicts.py data/dicts/danbooru.json + python cli/prune_dicts.py data/dicts/*.json --general 500 --artist 20 + python cli/prune_dicts.py data/dicts/sankaku.json -o data/dicts/sankaku-pruned.json + python cli/prune_dicts.py data/dicts/*.json --dry-run + +Category-aware pruning: artists and characters are kept at lower thresholds +(proper nouns that need autocomplete), while general tags are pruned more +aggressively (common vocabulary you'd type naturally). +""" + +import argparse +import json +import os +import sys + +# Category ID → name (must match UNIFIED_CATEGORIES in fetch_dicts.py) +CATEGORY_NAMES = { + 0: "general", + 1: "artist", + 2: "studio", + 3: "copyright", + 4: "character", + 5: "species", + 6: "genre", + 7: "medium", + 8: "meta", + 9: "lore", +} + +# Default minimum post counts per category. +# Low for proper nouns (hard to guess), high for common vocabulary (easy to type). +DEFAULTS = { + "general": 200, + "artist": 10, + "studio": 10, + "copyright": 20, + "character": 10, + "species": 50, + "genre": 100, + "medium": 100, + "meta": 1000, + "lore": 50, +} + + +def prune(tags: list, thresholds: dict[str, int]) -> tuple[list, dict[str, tuple[int, int]]]: + """Prune tags by per-category thresholds. + + Returns (pruned_tags, stats) where stats maps category name + to (before_count, after_count). + """ + stats: dict[str, tuple[int, int]] = {} + kept = [] + for tag in tags: + cat_id = tag[1] + cat_name = CATEGORY_NAMES.get(cat_id, "general") + threshold = thresholds.get(cat_name, thresholds.get("general", 10)) + before, after = stats.get(cat_name, (0, 0)) + before += 1 + if tag[2] >= threshold: + kept.append(tag) + after += 1 + stats[cat_name] = (before, after) + return kept, stats + + +def main(): + parser = argparse.ArgumentParser( + description="Prune tag dictionaries with per-category minimum post counts", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog="Defaults: " + ", ".join(f"{k}={v}" for k, v in DEFAULTS.items()), + ) + parser.add_argument("files", nargs="+", help="Dict JSON files to prune") + parser.add_argument("-o", "--output", help="Output file (single file mode only)") + parser.add_argument("--dry-run", action="store_true", help="Show what would be pruned without writing") + parser.add_argument("--in-place", "-i", action="store_true", help="Overwrite input files") + + for name, default in DEFAULTS.items(): + parser.add_argument(f"--{name}", type=int, default=default, help=f"Min posts for {name} (default: {default})") + + args = parser.parse_args() + + if args.output and len(args.files) > 1: + print("Error: --output can only be used with a single input file", file=sys.stderr) + sys.exit(1) + + if not args.output and not args.in_place and not args.dry_run: + print("Error: specify --in-place, --output, or --dry-run", file=sys.stderr) + sys.exit(1) + + thresholds = {name: getattr(args, name) for name in DEFAULTS} + + for filepath in args.files: + if not os.path.isfile(filepath): + print(f" Skipping {filepath}: not found", file=sys.stderr) + continue + + with open(filepath, encoding="utf-8") as f: + data = json.load(f) + + tags = data.get("tags", []) + pruned, stats = prune(tags, thresholds) + total_before = sum(s[0] for s in stats.values()) + total_after = sum(s[1] for s in stats.values()) + + name = data.get("name", os.path.basename(filepath)) + print(f"\n{name}: {total_before:,} → {total_after:,} tags ({total_before - total_after:,} removed)", file=sys.stderr) + for cat_name in sorted(stats, key=lambda c: list(CATEGORY_NAMES.values()).index(c) if c in CATEGORY_NAMES.values() else 99): + before, after = stats[cat_name] + threshold = thresholds.get(cat_name, 10) + removed = before - after + if removed > 0: + print(f" {cat_name:>12}: {before:>8,} → {after:>8,} (min {threshold:>5}, -{removed:,})", file=sys.stderr) + else: + print(f" {cat_name:>12}: {before:>8,} (min {threshold:>5}, all kept)", file=sys.stderr) + + if args.dry_run: + continue + + data["tags"] = pruned + output_path = args.output or filepath + tmp_path = output_path + ".tmp" + with open(tmp_path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, separators=(",", ":")) + os.replace(tmp_path, output_path) + size_mb = os.path.getsize(output_path) / (1024 * 1024) + print(f" Written: {output_path} ({size_mb:.1f} MB)", file=sys.stderr) + from gen_manifest import update_manifest + update_manifest(os.path.dirname(output_path) or ".") + + +if __name__ == "__main__": + main() diff --git a/data/dicts/art.json b/data/dicts/art.json new file mode 100644 index 000000000..be4ad0eff --- /dev/null +++ b/data/dicts/art.json @@ -0,0 +1 @@ +{"name":"art","version":"2026-03-24","categories":{"0":{"name":"general","color":"#0075f8"},"7":{"name":"medium","color":"#00cccc"}},"tags":[["abstract_art",0,0],["abstract_expressionism",0,0],["acrylic_paint",7,0],["aestheticism",0,0],["airbrush",7,0],["art_brut",0,0],["art_deco",0,0],["art_nouveau",0,0],["arte_povera",0,0],["arts_and_crafts_movement",0,0],["assemblage",7,0],["baroque",0,0],["bauhaus",0,0],["bistre",7,0],["block_print",7,0],["body_paint",7,0],["botanical_illustration",0,0],["brutalism",0,0],["calligraphy",7,0],["caricature",0,0],["cel_shading",7,0],["chalk",7,0],["chalk_pastel",7,0],["charcoal",7,0],["charcoal_drawing",7,0],["chinoiserie",0,0],["classicism",0,0],["cloisonnism",0,0],["collage",7,0],["color_field_painting",0,0],["color_pencil",7,0],["comic_art",0,0],["concept_art",0,0],["constructivism",0,0],["conte_crayon",7,0],["cross-hatching",7,0],["cubism",0,0],["dadaism",0,0],["de_stijl",0,0],["decoupage",7,0],["digital_art",7,0],["digital_painting",7,0],["divisionism",0,0],["drybrush",7,0],["drypoint",7,0],["egg_tempera",7,0],["embossing",7,0],["enameling",7,0],["encaustic",7,0],["engraving",7,0],["etching",7,0],["expressionism",0,0],["fantasy_art",0,0],["fashion_illustration",0,0],["fauvism",0,0],["figurative_art",0,0],["flat_color",7,0],["folk_art",0,0],["found_object",7,0],["fresco",7,0],["frottage",7,0],["futurism",0,0],["gesso",7,0],["gild_leaf",7,0],["gilding",7,0],["glaze",7,0],["gold_leaf",7,0],["gothic_art",0,0],["gouache",7,0],["graffiti",7,0],["graphite",7,0],["grisaille",7,0],["hatching",7,0],["hyperrealism",0,0],["icon_painting",0,0],["illuminated_manuscript",0,0],["illustration",0,0],["impasto",7,0],["impressionism",0,0],["ink",7,0],["ink_drawing",7,0],["ink_wash",7,0],["intaglio",7,0],["japonisme",0,0],["jugendstil",0,0],["kinetic_art",0,0],["landscape_painting",0,0],["letterpress",7,0],["linocut",7,0],["lithography",7,0],["lowbrow_art",0,0],["luminism",0,0],["manga_style",0,0],["mannerism",0,0],["matte_painting",7,0],["medieval_art",0,0],["metaphysical_art",0,0],["miniature_painting",0,0],["minimalism",0,0],["mixed_media",7,0],["modernism",0,0],["monoprint",7,0],["monotype",7,0],["mosaic",7,0],["mural",7,0],["naive_art",0,0],["naturalism",0,0],["neo-classicism",0,0],["neo-expressionism",0,0],["neo-impressionism",0,0],["neo-pop",0,0],["nihonga",0,0],["oil_paint",7,0],["oil_painting",7,0],["op_art",0,0],["orientalism",0,0],["orphism",0,0],["outsider_art",0,0],["palette_knife",7,0],["paper_cut",7,0],["pastel",7,0],["pen_and_ink",7,0],["pencil_drawing",7,0],["pencil_sketch",7,0],["photorealism",0,0],["plein_air",0,0],["pointillism",0,0],["political_art",0,0],["pop_art",0,0],["post-impressionism",0,0],["post-minimalism",0,0],["postmodernism",0,0],["pottery",7,0],["pre-raphaelite",0,0],["precisionism",0,0],["primitivism",0,0],["printmaking",7,0],["psychedelic_art",0,0],["realism",0,0],["relief_sculpture",7,0],["renaissance",0,0],["retrowave",0,0],["rococo",0,0],["romanticism",0,0],["scratchboard",7,0],["screen_print",7,0],["sfumato",7,0],["sgraffito",7,0],["silhouette",7,0],["silverpoint",7,0],["sketch",7,0],["social_realism",0,0],["spray_paint",7,0],["stained_glass",7,0],["stencil",7,0],["stippling",7,0],["street_art",0,0],["sumi-e",7,0],["superflat",0,0],["suprematism",0,0],["surrealism",0,0],["symbolism",0,0],["synthetism",0,0],["tapestry",7,0],["tempera",7,0],["tenebrism",0,0],["tessellation",7,0],["textile_art",7,0],["tonalism",0,0],["trompe_l'oeil",7,0],["ukiyo-e",0,0],["underpainting",7,0],["vaporwave",0,0],["vector_art",7,0],["venetian_painting",0,0],["vexel",7,0],["vorticism",0,0],["watercolor",7,0],["watercolor_painting",7,0],["wet-on-wet",7,0],["woodblock_print",7,0],["woodcut",7,0]]} \ No newline at end of file diff --git a/data/dicts/manifest.json b/data/dicts/manifest.json new file mode 100644 index 000000000..91a540750 --- /dev/null +++ b/data/dicts/manifest.json @@ -0,0 +1,67 @@ +{ + "dicts": [ + { + "name": "art", + "description": "Art movements, styles, and techniques", + "version": "2026-03-24", + "tag_count": 182, + "size_mb": 0.0 + }, + { + "name": "danbooru", + "description": "Danbooru image board tags - anime/illustration focused", + "version": "2026-03-24", + "tag_count": 270669, + "size_mb": 6.4 + }, + { + "name": "e621", + "description": "e621 tags - furry/animal art focused", + "version": "2026-03-24", + "tag_count": 175681, + "size_mb": 3.9 + }, + { + "name": "idol", + "description": "Idol Complex tags - Japanese idol photography", + "version": "2026-03-24", + "tag_count": 17311, + "size_mb": 0.4 + }, + { + "name": "negative", + "description": "Common negative prompt terms", + "version": "2026-03-24", + "tag_count": 173, + "size_mb": 0.0 + }, + { + "name": "photography", + "description": "Photography terms - lens, lighting, composition, color", + "version": "2026-03-24", + "tag_count": 257, + "size_mb": 0.0 + }, + { + "name": "quality", + "description": "Quality and aesthetic meta tags", + "version": "2026-03-24", + "tag_count": 126, + "size_mb": 0.0 + }, + { + "name": "rule34", + "description": "Rule34.xxx tags - multi-fandom", + "version": "2026-03-24", + "tag_count": 317011, + "size_mb": 7.2 + }, + { + "name": "sankaku", + "description": "Sankaku Complex tags - anime/illustration with granular categories", + "version": "2026-03-24", + "tag_count": 985257, + "size_mb": 22.7 + } + ] +} diff --git a/data/dicts/negative.json b/data/dicts/negative.json new file mode 100644 index 000000000..bf5c8b2e2 --- /dev/null +++ b/data/dicts/negative.json @@ -0,0 +1,182 @@ +{ + "name": "negative", + "version": "2026-03-24", + "categories": { + "8": {"name": "meta", "color": "#6b7280"} + }, + "tags": [ + ["3d_render", 8, 0], + ["amputee", 8, 0], + ["anatomical_error", 8, 0], + ["asymmetric_eyes", 8, 0], + ["bad_anatomy", 8, 0], + ["bad_composition", 8, 0], + ["bad_fingers", 8, 0], + ["bad_hands", 8, 0], + ["bad_lighting", 8, 0], + ["bad_proportions", 8, 0], + ["bad_quality", 8, 0], + ["bad_shadow", 8, 0], + ["blank_background", 8, 0], + ["blurry", 8, 0], + ["blurry_background", 8, 0], + ["blurry_face", 8, 0], + ["blurry_foreground", 8, 0], + ["body_horror", 8, 0], + ["boring", 8, 0], + ["broken_fingers", 8, 0], + ["cgi", 8, 0], + ["childish", 8, 0], + ["chromatic_aberration", 8, 0], + ["clipped", 8, 0], + ["cloned_face", 8, 0], + ["collage", 8, 0], + ["color_banding", 8, 0], + ["compression_artifacts", 8, 0], + ["contorted", 8, 0], + ["cropped", 8, 0], + ["cross-eyed", 8, 0], + ["cut_off", 8, 0], + ["dark", 8, 0], + ["deformed", 8, 0], + ["deformed_face", 8, 0], + ["deformed_fingers", 8, 0], + ["deformed_hands", 8, 0], + ["deformed_iris", 8, 0], + ["deformed_limbs", 8, 0], + ["deformed_pupils", 8, 0], + ["dehydrated", 8, 0], + ["disfigured", 8, 0], + ["disgusting", 8, 0], + ["disproportionate", 8, 0], + ["distorted", 8, 0], + ["distorted_face", 8, 0], + ["double_image", 8, 0], + ["draft", 8, 0], + ["dull", 8, 0], + ["duplicate", 8, 0], + ["elongated", 8, 0], + ["error", 8, 0], + ["extra_arms", 8, 0], + ["extra_digits", 8, 0], + ["extra_ears", 8, 0], + ["extra_eyes", 8, 0], + ["extra_fingers", 8, 0], + ["extra_hands", 8, 0], + ["extra_head", 8, 0], + ["extra_legs", 8, 0], + ["extra_limbs", 8, 0], + ["extra_nipples", 8, 0], + ["extra_toes", 8, 0], + ["fat", 8, 0], + ["flaw", 8, 0], + ["floating_limbs", 8, 0], + ["fused_fingers", 8, 0], + ["fused_limbs", 8, 0], + ["fuzzy", 8, 0], + ["glitch", 8, 0], + ["grain", 8, 0], + ["grainy", 8, 0], + ["gross", 8, 0], + ["gross_proportions", 8, 0], + ["hazy", 8, 0], + ["helmet", 8, 0], + ["heterochromia", 8, 0], + ["hideous", 8, 0], + ["icon", 8, 0], + ["jpeg_artifacts", 8, 0], + ["kitsch", 8, 0], + ["lazy_eye", 8, 0], + ["letterbox", 8, 0], + ["logo", 8, 0], + ["long_body", 8, 0], + ["long_neck", 8, 0], + ["low_contrast", 8, 0], + ["low_quality", 8, 0], + ["low_resolution", 8, 0], + ["low_saturation", 8, 0], + ["lowres", 8, 0], + ["malformed", 8, 0], + ["malformed_hands", 8, 0], + ["malformed_limbs", 8, 0], + ["mangled", 8, 0], + ["messy", 8, 0], + ["missing_arms", 8, 0], + ["missing_ears", 8, 0], + ["missing_eyes", 8, 0], + ["missing_fingers", 8, 0], + ["missing_hands", 8, 0], + ["missing_legs", 8, 0], + ["missing_limbs", 8, 0], + ["missing_teeth", 8, 0], + ["moire", 8, 0], + ["monochrome", 8, 0], + ["morbid", 8, 0], + ["multiple_views", 8, 0], + ["mutant", 8, 0], + ["mutated", 8, 0], + ["mutated_hands", 8, 0], + ["mutation", 8, 0], + ["mutilated", 8, 0], + ["noisy", 8, 0], + ["normal_quality", 8, 0], + ["off-center", 8, 0], + ["old", 8, 0], + ["old_photo", 8, 0], + ["out_of_focus", 8, 0], + ["out_of_frame", 8, 0], + ["over-saturated", 8, 0], + ["overexposed", 8, 0], + ["oversaturated", 8, 0], + ["owes", 8, 0], + ["painting", 8, 0], + ["pale_skin", 8, 0], + ["panel", 8, 0], + ["part_of_the_head", 8, 0], + ["photoshop", 8, 0], + ["pillarbox", 8, 0], + ["pixelated", 8, 0], + ["plastic", 8, 0], + ["poorly_drawn", 8, 0], + ["poorly_drawn_face", 8, 0], + ["poorly_drawn_hands", 8, 0], + ["poorly_rendered", 8, 0], + ["portrait", 8, 0], + ["render", 8, 0], + ["semi-realistic", 8, 0], + ["signature", 8, 0], + ["simple_background", 8, 0], + ["skin_blemishes", 8, 0], + ["skin_spots", 8, 0], + ["skinny", 8, 0], + ["sloppy", 8, 0], + ["smudge", 8, 0], + ["split_image", 8, 0], + ["squint", 8, 0], + ["strabismus", 8, 0], + ["stretched", 8, 0], + ["sunglasses", 8, 0], + ["surreal", 8, 0], + ["text", 8, 0], + ["tiling", 8, 0], + ["too_dark", 8, 0], + ["too_many_fingers", 8, 0], + ["two_heads", 8, 0], + ["ugly", 8, 0], + ["unclear", 8, 0], + ["underexposed", 8, 0], + ["unfocused", 8, 0], + ["unnatural", 8, 0], + ["unnatural_pose", 8, 0], + ["unnatural_skin", 8, 0], + ["unrealistic", 8, 0], + ["unsharp", 8, 0], + ["username", 8, 0], + ["washed_out", 8, 0], + ["watermark", 8, 0], + ["web_address", 8, 0], + ["weird_colors", 8, 0], + ["worst_quality", 8, 0], + ["wrong_proportions", 8, 0] + ] +} diff --git a/data/dicts/photography.json b/data/dicts/photography.json new file mode 100644 index 000000000..c96b7a392 --- /dev/null +++ b/data/dicts/photography.json @@ -0,0 +1 @@ +{"name":"photography","version":"2026-03-24","categories":{"10":{"name":"lens","color":"#e67e22"},"11":{"name":"lighting","color":"#f1c40f"},"12":{"name":"composition","color":"#1abc9c"},"13":{"name":"color","color":"#e84393"}},"tags":[["100mm",10,0],["105mm",10,0],["135mm",10,0],["14mm",10,0],["200mm",10,0],["20mm",10,0],["24mm",10,0],["28mm",10,0],["300mm",10,0],["35mm",10,0],["35mm_film",10,0],["400mm",10,0],["50mm",10,0],["600mm",10,0],["85mm",10,0],["aerial_shot",12,0],["ambient_lighting",11,0],["ambient_occlusion",11,0],["anamorphic_bokeh",10,0],["anamorphic_lens",10,0],["artificial_lighting",11,0],["asymmetrical_composition",12,0],["available_light",11,0],["backlight",11,0],["backlighting",11,0],["beauty_dish",11,0],["bird's_eye_view",12,0],["bleach_bypass",13,0],["blue_hour",11,0],["bokeh",10,0],["bounce_light",11,0],["broad_lighting",11,0],["butterfly_lighting",11,0],["candid",12,0],["candlelight",11,0],["catchlight",11,0],["centered_composition",12,0],["chiaroscuro",11,0],["chromatic",13,0],["chromatic_aberration",10,0],["cinematic_composition",12,0],["cinematic_lighting",11,0],["circular_bokeh",10,0],["clamshell_lighting",11,0],["close-up",12,0],["color_grading",13,0],["color_negative",13,0],["color_splash",13,0],["complementary_colors",13,0],["contre-jour",11,0],["cool_tones",13,0],["cowboy_shot",12,0],["crane_shot",12,0],["crepuscular_rays",11,0],["cross-processed",13,0],["cross_lighting",11,0],["crushed_blacks",13,0],["cyanotype",10,0],["daguerreotype",10,0],["dappled_light",11,0],["dawn_light",11,0],["day_for_night",13,0],["deep_depth_of_field",10,0],["desaturated",13,0],["detail_shot",12,0],["diagonal_composition",12,0],["diffused_light",11,0],["direct_sunlight",11,0],["documentary_style",12,0],["double_exposure",10,0],["dramatic_lighting",11,0],["drone_shot",12,0],["duotone",13,0],["dusk_light",11,0],["dutch_angle",12,0],["edge_lighting",11,0],["ektachrome",13,0],["environmental_portrait",12,0],["establishing_shot",12,0],["extreme_close-up",12,0],["eye_level",12,0],["f/1.2",10,0],["f/1.4",10,0],["f/1.8",10,0],["f/11",10,0],["f/16",10,0],["f/2",10,0],["f/2.8",10,0],["f/22",10,0],["f/4",10,0],["f/5.6",10,0],["f/8",10,0],["face_shot",12,0],["faded_colors",13,0],["fibonacci_spiral",12,0],["fill_light",11,0],["film_grain",10,0],["fisheye_lens",10,0],["flat_lay",12,0],["flat_lighting",11,0],["fluorescent_lighting",11,0],["fog_light",11,0],["foreground_framing",12,0],["frame_within_a_frame",12,0],["front_lighting",11,0],["fujifilm_pro_400h",13,0],["fujifilm_superia",13,0],["fujifilm_velvia",13,0],["full_body_shot",12,0],["global_illumination",11,0],["god_rays",11,0],["golden_hour",11,0],["golden_ratio",12,0],["golden_triangle",12,0],["grain",10,0],["hair_light",11,0],["hard_light",11,0],["harsh_light",11,0],["head_shot",12,0],["high_angle",12,0],["high_contrast",13,0],["high_key_lighting",11,0],["high_saturation",13,0],["ilford_hp5",13,0],["infrared_photography",10,0],["instant_film",10,0],["isometric_view",12,0],["key_light",11,0],["kicker_light",11,0],["knee_shot",12,0],["kodachrome",13,0],["kodak_ektar_100",13,0],["kodak_gold_200",13,0],["kodak_portra_160",13,0],["kodak_portra_400",13,0],["kodak_portra_800",13,0],["kodak_tri-x_400",13,0],["lamp_light",11,0],["landscape_orientation",12,0],["large_format",10,0],["leading_lines",12,0],["lens_distortion",10,0],["lens_flare",10,0],["lifted_blacks",13,0],["light_trails",10,0],["lomo",13,0],["long_exposure",10,0],["long_shot",12,0],["looking_down",12,0],["looking_up",12,0],["loop_lighting",11,0],["low_angle",12,0],["low_contrast",13,0],["low_key_lighting",11,0],["low_saturation",13,0],["lut",13,0],["macro_lens",10,0],["macro_photography",10,0],["magic_hour",11,0],["medium_close-up",12,0],["medium_format",10,0],["medium_shot",12,0],["micro_photography",10,0],["minimalist_composition",12,0],["monochrome",13,0],["moonlight",11,0],["motion_blur",10,0],["motivated_lighting",11,0],["multiple_exposure",10,0],["muted_colors",13,0],["natural_lighting",11,0],["negative_space",12,0],["neon_lighting",11,0],["noise",10,0],["noon_sun",11,0],["one-point_perspective",12,0],["orange_and_teal",13,0],["over_the_shoulder",12,0],["overcast_light",11,0],["overhead_shot",12,0],["oversaturated",13,0],["panning_shot",10,0],["panoramic",12,0],["paramount_lighting",11,0],["pastel_colors",13,0],["pinhole_camera",10,0],["point_of_view",12,0],["polaroid",10,0],["portrait_orientation",12,0],["practical_lighting",11,0],["prime_lens",10,0],["profile_view",12,0],["push_processed",13,0],["radial_composition",12,0],["ray_of_light",11,0],["reflected_light",11,0],["rembrandt_lighting",11,0],["rim_light",11,0],["ring_light",11,0],["rule_of_thirds",12,0],["selective_focus",12,0],["sepia",13,0],["shallow_depth_of_field",10,0],["short_lighting",11,0],["side_lighting",11,0],["silhouette_lighting",11,0],["slide_film",13,0],["soft_focus",10,0],["soft_light",11,0],["softbox",11,0],["specular_highlight",11,0],["split_lighting",11,0],["split_toning",13,0],["spot_light",11,0],["square_format",12,0],["star_trails",10,0],["stereoscopic",10,0],["straight-on_view",12,0],["street_photography",12,0],["strobe_light",11,0],["studio_lighting",11,0],["subsurface_scattering",11,0],["sunbeam",11,0],["swirly_bokeh",10,0],["symmetrical_composition",12,0],["teal_and_orange",13,0],["technicolor",13,0],["telephoto_compression",10,0],["telephoto_lens",10,0],["three-point_lighting",11,0],["three-quarter_view",12,0],["tilt-shift",10,0],["tilt-shift_photography",10,0],["tintype",10,0],["top_lighting",11,0],["tracking_shot",12,0],["tricolor",13,0],["tungsten_lighting",11,0],["twilight",11,0],["two-point_perspective",12,0],["two_shot",12,0],["ultra_wide_angle",10,0],["ultraviolet_photography",10,0],["under_lighting",11,0],["uplighting",11,0],["vanishing_point",12,0],["vignette",10,0],["vintage_color",13,0],["volumetric_lighting",11,0],["warm_lighting",11,0],["warm_tones",13,0],["wide_angle_lens",10,0],["wide_shot",12,0],["window_light",11,0],["worm's_eye_view",12,0],["zoom_blur",10,0],["zoom_lens",10,0]]} \ No newline at end of file diff --git a/data/dicts/quality.json b/data/dicts/quality.json new file mode 100644 index 000000000..7a431fe3e --- /dev/null +++ b/data/dicts/quality.json @@ -0,0 +1,135 @@ +{ + "name": "quality", + "version": "2026-03-24", + "categories": { + "8": {"name": "meta", "color": "#6b7280"} + }, + "tags": [ + ["16k", 8, 0], + ["4k", 8, 0], + ["8k", 8, 0], + ["8k_uhd", 8, 0], + ["absurdly_detailed", 8, 0], + ["aesthetic", 8, 0], + ["amateur", 8, 0], + ["analog_photo", 8, 0], + ["anatomically_correct", 8, 0], + ["anti-aliased", 8, 0], + ["artstation", 8, 0], + ["award-winning", 8, 0], + ["award-winning_photo", 8, 0], + ["best_quality", 8, 0], + ["breathtaking", 8, 0], + ["cgi", 8, 0], + ["cinematic", 8, 0], + ["cinematic_still", 8, 0], + ["clean_lines", 8, 0], + ["crisp", 8, 0], + ["crystal_clear", 8, 0], + ["cute", 8, 0], + ["dark_fantasy", 8, 0], + ["delicate_details", 8, 0], + ["depth", 8, 0], + ["detailed", 8, 0], + ["detailed_background", 8, 0], + ["detailed_face", 8, 0], + ["detailed_hands", 8, 0], + ["detailed_skin", 8, 0], + ["detailed_texture", 8, 0], + ["deviantart", 8, 0], + ["dslr", 8, 0], + ["dslr_photo", 8, 0], + ["editorial_photo", 8, 0], + ["elaborate", 8, 0], + ["elegant", 8, 0], + ["epic", 8, 0], + ["epic_composition", 8, 0], + ["ethereal", 8, 0], + ["exquisite_detail", 8, 0], + ["extremely_detailed", 8, 0], + ["fashion_photo", 8, 0], + ["filmic", 8, 0], + ["fine_art", 8, 0], + ["fine_detail", 8, 0], + ["flat_design", 8, 0], + ["full_color", 8, 0], + ["gorgeous", 8, 0], + ["hdr", 8, 0], + ["high_definition", 8, 0], + ["high_detail", 8, 0], + ["high_fidelity", 8, 0], + ["high_quality", 8, 0], + ["high_resolution", 8, 0], + ["highest_quality", 8, 0], + ["highly_detailed", 8, 0], + ["hyper-detailed", 8, 0], + ["hyper-realistic", 8, 0], + ["hyperdetailed", 8, 0], + ["hyperrealistic", 8, 0], + ["illustration", 8, 0], + ["immersive", 8, 0], + ["intricate", 8, 0], + ["intricate_details", 8, 0], + ["lifelike", 8, 0], + ["lossless", 8, 0], + ["lush", 8, 0], + ["majestic", 8, 0], + ["masterful", 8, 0], + ["masterpiece", 8, 0], + ["masterwork", 8, 0], + ["meticulous", 8, 0], + ["moody", 8, 0], + ["national_geographic", 8, 0], + ["octane_render", 8, 0], + ["opulent", 8, 0], + ["ornate", 8, 0], + ["painterly", 8, 0], + ["perfect_anatomy", 8, 0], + ["perfect_composition", 8, 0], + ["perfect_face", 8, 0], + ["perfect_hands", 8, 0], + ["perfect_lighting", 8, 0], + ["photo-realistic", 8, 0], + ["photographic", 8, 0], + ["photography", 8, 0], + ["photojournalism", 8, 0], + ["photorealistic", 8, 0], + ["pixel_perfect", 8, 0], + ["polished", 8, 0], + ["portfolio", 8, 0], + ["precise", 8, 0], + ["production_quality", 8, 0], + ["professional", 8, 0], + ["professional_photo", 8, 0], + ["raw_photo", 8, 0], + ["realistic", 8, 0], + ["refined", 8, 0], + ["render", 8, 0], + ["resolution", 8, 0], + ["sharp", 8, 0], + ["sharp_focus", 8, 0], + ["smooth", 8, 0], + ["soft_render", 8, 0], + ["sophisticated", 8, 0], + ["stunning", 8, 0], + ["stylized", 8, 0], + ["subsurface_scattering", 8, 0], + ["textured", 8, 0], + ["trending_on_artstation", 8, 0], + ["uhd", 8, 0], + ["ultra-detailed", 8, 0], + ["ultra-realistic", 8, 0], + ["ultra_detailed", 8, 0], + ["ultra_hd", 8, 0], + ["ultra_high_resolution", 8, 0], + ["ultra_realistic", 8, 0], + ["ultra_sharp", 8, 0], + ["unreal_engine", 8, 0], + ["unreal_engine_5", 8, 0], + ["very_detailed", 8, 0], + ["vibrant", 8, 0], + ["vivid", 8, 0], + ["volumetric", 8, 0], + ["vray", 8, 0] + ] +}