realphongha commited on
Commit
a5b1307
Β·
1 Parent(s): 0ca4d5d

add gradio app

Browse files
Files changed (5) hide show
  1. .gitattributes +0 -0
  2. .gitignore +223 -0
  3. README.md +23 -7
  4. app.py +718 -0
  5. requirements.txt +7 -0
.gitattributes CHANGED
File without changes
.gitignore ADDED
@@ -0,0 +1,223 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Project-specific
2
+ data/
3
+
4
+ # Byte-compiled / optimized / DLL files
5
+ __pycache__/
6
+ *.py[codz]
7
+ *$py.class
8
+
9
+ # C extensions
10
+ *.so
11
+
12
+ # Distribution / packaging
13
+ .Python
14
+ build/
15
+ develop-eggs/
16
+ dist/
17
+ downloads/
18
+ eggs/
19
+ .eggs/
20
+ lib/
21
+ lib64/
22
+ parts/
23
+ sdist/
24
+ var/
25
+ wheels/
26
+ share/python-wheels/
27
+ *.egg-info/
28
+ .installed.cfg
29
+ *.egg
30
+ MANIFEST
31
+
32
+ # PyInstaller
33
+ # Usually these files are written by a python script from a template
34
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
35
+ *.manifest
36
+ *.spec
37
+
38
+ # Installer logs
39
+ pip-log.txt
40
+ pip-delete-this-directory.txt
41
+
42
+ # Unit test / coverage reports
43
+ htmlcov/
44
+ .tox/
45
+ .nox/
46
+ .coverage
47
+ .coverage.*
48
+ .cache
49
+ nosetests.xml
50
+ coverage.xml
51
+ *.cover
52
+ *.py.cover
53
+ *.lcov
54
+ .hypothesis/
55
+ .pytest_cache/
56
+ cover/
57
+
58
+ # Translations
59
+ *.mo
60
+ *.pot
61
+
62
+ # Django stuff:
63
+ *.log
64
+ local_settings.py
65
+ db.sqlite3
66
+ db.sqlite3-journal
67
+
68
+ # Flask stuff:
69
+ instance/
70
+ .webassets-cache
71
+
72
+ # Scrapy stuff:
73
+ .scrapy
74
+
75
+ # Sphinx documentation
76
+ docs/_build/
77
+
78
+ # PyBuilder
79
+ .pybuilder/
80
+ target/
81
+
82
+ # Jupyter Notebook
83
+ .ipynb_checkpoints
84
+
85
+ # IPython
86
+ profile_default/
87
+ ipython_config.py
88
+
89
+ # pyenv
90
+ # For a library or package, you might want to ignore these files since the code is
91
+ # intended to run in multiple environments; otherwise, check them in:
92
+ # .python-version
93
+
94
+ # pipenv
95
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
96
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
97
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
98
+ # install all needed dependencies.
99
+ # Pipfile.lock
100
+
101
+ # UV
102
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
103
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
104
+ # commonly ignored for libraries.
105
+ # uv.lock
106
+
107
+ # poetry
108
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
109
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
110
+ # commonly ignored for libraries.
111
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
112
+ # poetry.lock
113
+ # poetry.toml
114
+
115
+ # pdm
116
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
117
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
118
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
119
+ # pdm.lock
120
+ # pdm.toml
121
+ .pdm-python
122
+ .pdm-build/
123
+
124
+ # pixi
125
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
126
+ # pixi.lock
127
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
128
+ # in the .venv directory. It is recommended not to include this directory in version control.
129
+ .pixi/*
130
+ !.pixi/config.toml
131
+
132
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
133
+ __pypackages__/
134
+
135
+ # Celery stuff
136
+ celerybeat-schedule*
137
+ celerybeat.pid
138
+
139
+ # Redis
140
+ *.rdb
141
+ *.aof
142
+ *.pid
143
+
144
+ # RabbitMQ
145
+ mnesia/
146
+ rabbitmq/
147
+ rabbitmq-data/
148
+
149
+ # ActiveMQ
150
+ activemq-data/
151
+
152
+ # SageMath parsed files
153
+ *.sage.py
154
+
155
+ # Environments
156
+ .env
157
+ .envrc
158
+ .venv
159
+ env/
160
+ venv/
161
+ ENV/
162
+ env.bak/
163
+ venv.bak/
164
+
165
+ # Spyder project settings
166
+ .spyderproject
167
+ .spyproject
168
+
169
+ # Rope project settings
170
+ .ropeproject
171
+
172
+ # mkdocs documentation
173
+ /site
174
+
175
+ # mypy
176
+ .mypy_cache/
177
+ .dmypy.json
178
+ dmypy.json
179
+
180
+ # Pyre type checker
181
+ .pyre/
182
+
183
+ # pytype static type analyzer
184
+ .pytype/
185
+
186
+ # Cython debug symbols
187
+ cython_debug/
188
+
189
+ # PyCharm
190
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
191
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
192
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
193
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
194
+ # .idea/
195
+
196
+ # Abstra
197
+ # Abstra is an AI-powered process automation framework.
198
+ # Ignore directories containing user credentials, local state, and settings.
199
+ # Learn more at https://abstra.io/docs
200
+ .abstra/
201
+
202
+ # Visual Studio Code
203
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
204
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
205
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
206
+ # you could uncomment the following to ignore the entire vscode folder
207
+ # .vscode/
208
+ # Temporary file for partial code execution
209
+ tempCodeRunnerFile.py
210
+
211
+ # Ruff stuff:
212
+ .ruff_cache/
213
+
214
+ # PyPI configuration file
215
+ .pypirc
216
+
217
+ # Marimo
218
+ marimo/_static/
219
+ marimo/_lsp/
220
+ __marimo__/
221
+
222
+ # Streamlit
223
+ .streamlit/secrets.toml
README.md CHANGED
@@ -1,14 +1,30 @@
1
  ---
2
  title: DanbooruTagQuery
3
- emoji: πŸ¦€
4
- colorFrom: gray
5
- colorTo: pink
6
  sdk: gradio
7
- sdk_version: 6.20.0
8
- python_version: '3.12'
9
  app_file: app.py
10
  pinned: false
11
- license: other
12
  ---
13
 
14
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: DanbooruTagQuery
3
+ emoji: 🏷️
4
+ colorFrom: indigo
5
+ colorTo: purple
6
  sdk: gradio
7
+ sdk_version: 5.23.3
 
8
  app_file: app.py
9
  pinned: false
 
10
  ---
11
 
12
+ # 🏷️ DanbooruTagQuery
13
+
14
+ ONNX-powered anime image tagger. Upload an image or paste a URL to get predicted Danbooru tags with scores, categories, and wiki lookups.
15
+
16
+ ## Local testing
17
+
18
+ ```bash
19
+ # Use a local model file
20
+ python app.py /path/to/DanbooruTagQuery_b16_448x448.onnx
21
+
22
+ # Or point to a directory with .onnx + sidecar files
23
+ export MODEL_DIR=/path/to/models/
24
+ python app.py
25
+ ```
26
+
27
+ ## Credits
28
+
29
+ - Model: [realphongha/danbooru-tag-query](https://huggingface.co/realphongha/danbooru-tag-query)
30
+ - Dataset: [danbooru-tags](https://huggingface.co/datasets/qdlabs/danbooru-tags)
app.py ADDED
@@ -0,0 +1,718 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ DanbooruTagQuery β€” Hugging Face ZeroGPU Space
4
+
5
+ Usage:
6
+ python app.py # download model from HF hub
7
+ python app.py /path/to/model.onnx # use local model
8
+ MODEL_DIR=/path/to python app.py # env var with model dir
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ import sqlite3
16
+ import sys
17
+ import tempfile
18
+ import threading
19
+ import time
20
+ from pathlib import Path
21
+
22
+ import gradio as gr
23
+ import numpy as np
24
+ from PIL import Image
25
+
26
+ # ── optional deps (loaded on demand) ────────────────────────────────────────
27
+
28
+ _hf_hub = None
29
+
30
+ def _import_hf_hub():
31
+ global _hf_hub
32
+ if _hf_hub is None:
33
+ import huggingface_hub as h
34
+ _hf_hub = h
35
+ return _hf_hub
36
+
37
+
38
+ # ── constants ───────────────────────────────────────────────────────────────
39
+
40
+ HF_REPO = "realphongha/danbooru-tag-query"
41
+ MODELS_DIR = "models"
42
+
43
+ IMAGENET_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)
44
+ IMAGENET_STD = np.array([0.229, 0.224, 0.225], dtype=np.float32)
45
+
46
+ CATEGORY_MAP = {
47
+ 0: "general",
48
+ 1: "artist",
49
+ 3: "copyright",
50
+ 4: "character",
51
+ 5: "meta",
52
+ }
53
+
54
+ DEFAULT_TOP_K = None
55
+ DEFAULT_MIN_SCORE = 0.2
56
+
57
+ # ── TagCache (SQLite) ───────────────────────────────────────────────────────
58
+
59
+ _DB_PATH = Path("data/tag_cache.db")
60
+
61
+
62
+ class TagCache:
63
+ def __init__(self, db: str | Path = _DB_PATH):
64
+ self.db = Path(db)
65
+ self.db.parent.mkdir(parents=True, exist_ok=True)
66
+ self._conn = sqlite3.connect(str(self.db), check_same_thread=False)
67
+ self._lock = threading.Lock()
68
+ self._conn.execute(
69
+ """CREATE TABLE IF NOT EXISTS tag_cache (
70
+ name TEXT PRIMARY KEY,
71
+ category INTEGER,
72
+ wiki_body TEXT,
73
+ fetched_at REAL NOT NULL
74
+ )"""
75
+ )
76
+ self._conn.commit()
77
+
78
+ def get(self, name: str) -> tuple[int | None, str | None] | None:
79
+ with self._lock:
80
+ cur = self._conn.execute(
81
+ "SELECT category, wiki_body FROM tag_cache WHERE name = ?", (name,)
82
+ )
83
+ return cur.fetchone()
84
+
85
+ def set(self, name: str, category: int | None, wiki_body: str | None):
86
+ with self._lock:
87
+ self._conn.execute(
88
+ """INSERT OR REPLACE INTO tag_cache (name, category, wiki_body, fetched_at)
89
+ VALUES (?, ?, ?, ?)""",
90
+ (name, category, wiki_body, time.time()),
91
+ )
92
+ self._conn.commit()
93
+
94
+ def get_many(self, names: list[str]) -> dict[str, tuple[int | None, str | None]]:
95
+ if not names:
96
+ return {}
97
+ with self._lock:
98
+ placeholders = ",".join("?" for _ in names)
99
+ cur = self._conn.execute(
100
+ f"SELECT name, category, wiki_body FROM tag_cache "
101
+ f"WHERE name IN ({placeholders})", names,
102
+ )
103
+ return {row[0]: (row[1], row[2]) for row in cur}
104
+
105
+ def bulk_set(self, items: list[tuple[str, int | None, str | None]]):
106
+ with self._lock:
107
+ now = time.time()
108
+ self._conn.executemany(
109
+ """INSERT OR REPLACE INTO tag_cache (name, category, wiki_body, fetched_at)
110
+ VALUES (?, ?, ?, ?)""",
111
+ [(name, cat, body, now) for name, cat, body in items],
112
+ )
113
+ self._conn.commit()
114
+
115
+ def clear(self):
116
+ with self._lock:
117
+ self._conn.execute("DELETE FROM tag_cache")
118
+ self._conn.commit()
119
+
120
+ def size(self) -> int:
121
+ with self._lock:
122
+ cur = self._conn.execute("SELECT COUNT(*) FROM tag_cache")
123
+ return cur.fetchone()[0]
124
+
125
+
126
+ # ── image preprocessing ────────────────────────────────────────────────────
127
+
128
+ def preprocess(image: Image.Image, image_size: int = 448) -> np.ndarray:
129
+ w, h = image.size
130
+ scale = image_size / max(w, h)
131
+ new_w = int(w * scale)
132
+ new_h = int(h * scale)
133
+ image = image.resize((new_w, new_h), Image.BILINEAR)
134
+ canvas = Image.new("RGB", (image_size, image_size), (0, 0, 0))
135
+ left = (image_size - new_w) // 2
136
+ top = (image_size - new_h) // 2
137
+ canvas.paste(image, (left, top))
138
+ arr = np.asarray(canvas, dtype=np.float32).transpose(2, 0, 1) / 255.0
139
+ arr[0] = (arr[0] - IMAGENET_MEAN[0]) / IMAGENET_STD[0]
140
+ arr[1] = (arr[1] - IMAGENET_MEAN[1]) / IMAGENET_STD[1]
141
+ arr[2] = (arr[2] - IMAGENET_MEAN[2]) / IMAGENET_STD[2]
142
+ return arr[np.newaxis, ...]
143
+
144
+
145
+ # ── sidecar loading ───────────────────────���────────────────────────────────
146
+
147
+ def _sidecar_path(checkpoint: Path, suffix: str) -> Path:
148
+ if checkpoint.suffix == ".onnx":
149
+ return checkpoint.with_name(checkpoint.stem + suffix)
150
+ return checkpoint / suffix.lstrip(".")
151
+
152
+
153
+ def load_tag_to_id(checkpoint: str | Path) -> dict[str, int]:
154
+ ckpt = Path(checkpoint)
155
+ path = _sidecar_path(ckpt, ".tag_to_id.json")
156
+ if not path.exists():
157
+ path = ckpt.parent / "tag_to_id.json"
158
+ if not path.exists():
159
+ raise FileNotFoundError(f"Missing tag map: {path}")
160
+ return json.loads(path.read_text())
161
+
162
+
163
+ def load_config(checkpoint: str | Path) -> dict:
164
+ ckpt = Path(checkpoint)
165
+ path = _sidecar_path(ckpt, ".config.json")
166
+ if not path.exists():
167
+ path = ckpt.parent / "config.json"
168
+ if not path.exists():
169
+ return {"image_size": 448}
170
+ return json.loads(path.read_text())
171
+
172
+
173
+ # ── Predictor (ONNX) ──────────────────────────────────────────────────────
174
+
175
+ class Predictor:
176
+ def __init__(self, checkpoint: str | Path):
177
+ import onnxruntime as ort
178
+
179
+ self.checkpoint = str(checkpoint)
180
+ self.tag_to_id = load_tag_to_id(self.checkpoint)
181
+ cfg = load_config(self.checkpoint)
182
+ self.image_size = cfg.get("image_size", 448)
183
+
184
+ providers = [
185
+ ("CUDAExecutionProvider", {}),
186
+ "CPUExecutionProvider",
187
+ ]
188
+ try:
189
+ self._sess = ort.InferenceSession(self.checkpoint, providers=providers)
190
+ except Exception:
191
+ self._sess = ort.InferenceSession(
192
+ self.checkpoint, providers=["CPUExecutionProvider"]
193
+ )
194
+
195
+ self._input_name = self._sess.get_inputs()[0].name
196
+ self._output_name = self._sess.get_outputs()[0].name
197
+
198
+ def run(self, pixel_values: np.ndarray) -> np.ndarray:
199
+ raw = self._sess.run([self._output_name], {self._input_name: pixel_values})[0]
200
+ return 1.0 / (1.0 + np.exp(-raw))
201
+
202
+ @property
203
+ def num_classes(self) -> int:
204
+ return len(self.tag_to_id)
205
+
206
+
207
+ # ── model discovery & loading (HF hub) ─────────────────────────────────────
208
+
209
+ def discover_model_variants() -> list[str]:
210
+ try:
211
+ hf = _import_hf_hub()
212
+ api = hf.HfApi()
213
+ siblings = api.list_repo_files(HF_REPO, repo_type="model")
214
+ variants: set[str] = set()
215
+ for path in siblings:
216
+ if path.startswith(f"{MODELS_DIR}/") and "/" in path[len(MODELS_DIR) + 1:]:
217
+ variant = path.split("/")[1]
218
+ if variant:
219
+ variants.add(variant)
220
+ return sorted(variants, reverse=True)
221
+ except Exception as exc:
222
+ print(f"Warning: could not discover models on hub: {exc}")
223
+ return []
224
+
225
+
226
+ def resolve_model_path(variant: str) -> str:
227
+ onnx_path = f"{MODELS_DIR}/{variant}/model.onnx"
228
+ hf = _import_hf_hub()
229
+ return hf.hf_hub_download(
230
+ repo_id=HF_REPO,
231
+ filename=onnx_path,
232
+ repo_type="model",
233
+ )
234
+
235
+
236
+ def resolve_sidecar_paths(variant: str) -> tuple[str, str]:
237
+ hf = _import_hf_hub()
238
+ config_path = hf.hf_hub_download(
239
+ repo_id=HF_REPO,
240
+ filename=f"{MODELS_DIR}/{variant}/config.json",
241
+ repo_type="model",
242
+ )
243
+ tagmap_path = hf.hf_hub_download(
244
+ repo_id=HF_REPO,
245
+ filename=f"{MODELS_DIR}/{variant}/tag_to_id.json",
246
+ repo_type="model",
247
+ )
248
+ return config_path, tagmap_path
249
+
250
+
251
+ def download_model_variant(variant: str) -> Path:
252
+ onnx = Path(resolve_model_path(variant))
253
+ resolve_sidecar_paths(variant)
254
+ return onnx
255
+
256
+
257
+ # ── tag enrichment (category lookup, no API) ───────────────────────────────
258
+
259
+ _CACHE = TagCache()
260
+
261
+
262
+ def prebuild_cache(tag_to_id: dict[str, int]):
263
+ """Pre-populate tag cache from HF dataset.
264
+
265
+ Every tag gets a category (defaults to 0 = general).
266
+ No lazy API calls needed.
267
+ """
268
+ try:
269
+ from datasets import load_dataset
270
+ except ImportError:
271
+ return 0, 0
272
+
273
+ tags = sorted(tag_to_id.keys(), key=lambda t: tag_to_id[t])
274
+
275
+ try:
276
+ ds = load_dataset("qdlabs/danbooru-tags", split="train")
277
+ cat_map = {row["name"]: row["category"] for row in ds}
278
+ except Exception:
279
+ return 0, 0
280
+
281
+ found = sum(1 for t in tags if t in cat_map)
282
+ todo = []
283
+ already = 0
284
+ for tag in tags:
285
+ cached = _CACHE.get(tag)
286
+ if cached is not None and cached[0] is not None:
287
+ already += 1
288
+ continue
289
+ cat = cat_map.get(tag, 0) # 0 = general for unknown
290
+ existing_wiki = cached[1] if cached else None
291
+ todo.append((tag, cat, existing_wiki))
292
+
293
+ if todo:
294
+ _CACHE.bulk_set(todo)
295
+
296
+ return already + len(todo), found
297
+
298
+
299
+ def enrich_tags(tags_scores: list[tuple[str, float]]) -> dict[str, dict]:
300
+ """Attach category info to each tag. No API calls."""
301
+ tags = [t for t, _ in tags_scores]
302
+ cached_map = _CACHE.get_many(tags)
303
+ result: dict[str, dict] = {}
304
+ for tag, score in tags_scores:
305
+ cached = cached_map.get(tag)
306
+ if cached is not None:
307
+ cat_id, _ = cached
308
+ else:
309
+ cat_id = None
310
+ result[tag] = {
311
+ "score": score,
312
+ "category": cat_id,
313
+ "category_name": CATEGORY_MAP.get(cat_id, "general"),
314
+ }
315
+ return result
316
+
317
+
318
+ def format_tag(tag: str, use_underscore: bool) -> str:
319
+ return tag if use_underscore else tag.replace("_", " ")
320
+
321
+
322
+ # ── Gradio UI ──────────────────────────────────────────────────────────────
323
+
324
+ def build_app(predict_fn, model_choices: list[str]) -> gr.Blocks:
325
+ state = {
326
+ "all_logits": None,
327
+ "tag_metadata": None,
328
+ "current_image": None,
329
+ "predictor": None,
330
+ "predict_fn": predict_fn,
331
+ }
332
+
333
+ css = """
334
+ #csv-wrap { position: relative; }
335
+ #copy-csv-btn { position: absolute; top: 4px; right: 4px; z-index: 10;
336
+ min-width: 0; padding: 0 6px; height: 24px;
337
+ font-size: 13px; line-height: 24px; }
338
+ """
339
+
340
+ category_names = sorted(CATEGORY_MAP.values())
341
+
342
+ with gr.Blocks(title="DanbooruTagQuery", theme=gr.themes.Soft(), css=css) as app:
343
+ gr.Markdown("# 🏷️ DanbooruTagQuery")
344
+
345
+ with gr.Row():
346
+ # ── left: image + model ──
347
+ with gr.Column(scale=1):
348
+ image_input = gr.Image(
349
+ label="Image",
350
+ type="pil",
351
+ sources=["upload", "clipboard"],
352
+ height=300,
353
+ )
354
+ url_input = gr.Textbox(
355
+ label="Image URL",
356
+ placeholder="Paste image URL and press Enter",
357
+ )
358
+ with gr.Row():
359
+ analyze_btn = gr.Button("πŸ” Analyze", variant="primary", scale=2)
360
+ clear_btn = gr.Button("πŸ—‘οΈ Clear", scale=1)
361
+
362
+ gr.Markdown("### πŸ€– Model")
363
+ model_dropdown = gr.Dropdown(
364
+ choices=model_choices,
365
+ value=model_choices[0] if model_choices else None,
366
+ label="Model variant",
367
+ interactive=True,
368
+ )
369
+ model_status = gr.Markdown("Ready")
370
+
371
+ # ── right: params + categories ──
372
+ with gr.Column(scale=1):
373
+ top_k = gr.Number(
374
+ label="Top-K", value=DEFAULT_TOP_K, minimum=0, step=1
375
+ )
376
+ min_score = gr.Slider(
377
+ label="Min Score",
378
+ value=DEFAULT_MIN_SCORE,
379
+ minimum=0.0,
380
+ maximum=1.0,
381
+ step=0.01,
382
+ )
383
+ sort_by = gr.Radio(
384
+ label="Sort by", choices=["score", "name"], value="score"
385
+ )
386
+ use_underscore = gr.Checkbox(
387
+ label="Use underscore (_)", value=False
388
+ )
389
+ categories = gr.CheckboxGroup(
390
+ label="Categories",
391
+ choices=category_names,
392
+ value=["general"],
393
+ )
394
+
395
+ # ── outputs ──
396
+ with gr.Tabs():
397
+ with gr.TabItem("πŸ“‹ Tag list"):
398
+ tag_table = gr.HTML(label="Tags")
399
+ with gr.TabItem("πŸ“ Comma-separated"):
400
+ with gr.Column(elem_id="csv-wrap"):
401
+ tag_string = gr.Textbox(label="Tags", lines=6, elem_id="csv-text")
402
+ copy_btn = gr.Button("πŸ“‹", elem_id="copy-csv-btn")
403
+
404
+ # ── status row ──
405
+ with gr.Row():
406
+ status = gr.Markdown("Ready. Load an image and click **Analyze**.")
407
+ clear_cache_btn = gr.Button("🧹 Clear cache", size="sm", elem_id="clear-cache-btn")
408
+
409
+ # ── tag score query ──
410
+ gr.Markdown("### πŸ” Tag score query")
411
+ with gr.Row():
412
+ tag_query = gr.Textbox(
413
+ label="Search tag",
414
+ placeholder="Type to search…",
415
+ scale=3,
416
+ )
417
+ tag_query_output = gr.HTML(label="Results")
418
+
419
+ # ── callbacks ──────────────────────────────────────────────────────
420
+
421
+ def refresh_results(
422
+ _top_k, _min_score, _sort_by, _use_underscore, _categories,
423
+ ):
424
+ if state["all_logits"] is None or state["tag_metadata"] is None:
425
+ return "<i>No results yet.</i>", ""
426
+
427
+ meta = state["tag_metadata"]
428
+ all_tags = list(meta.keys())
429
+ if _categories:
430
+ all_tags = [
431
+ t for t in all_tags
432
+ if meta[t]["category_name"] in _categories
433
+ ]
434
+
435
+ items = [(t, meta[t]["score"]) for t in all_tags]
436
+ if _sort_by == "name":
437
+ items.sort(key=lambda x: format_tag(x[0], _use_underscore))
438
+ else:
439
+ items.sort(key=lambda x: x[1], reverse=True)
440
+
441
+ items = [(t, s) for t, s in items if s >= _min_score]
442
+ if _top_k is not None and _top_k > 0:
443
+ items = items[:_top_k]
444
+
445
+ if not items:
446
+ return "<i>No tags pass the filters.</i>", ""
447
+
448
+ rows = []
449
+ for tag, score in items:
450
+ m = meta[tag]
451
+ link = (
452
+ f'<a href="https://danbooru.donmai.us/posts?tags={tag}"'
453
+ f' target="_blank">{tag}</a>'
454
+ )
455
+ display = format_tag(tag, _use_underscore)
456
+ rows.append(
457
+ f"<tr>"
458
+ f"<td>{link}</td>"
459
+ f"<td>{display}</td>"
460
+ f"<td style='text-align:right'>{score:.4f}</td>"
461
+ f"<td><code>{m['category_name'] or '?'}</code></td>"
462
+ f"</tr>"
463
+ )
464
+ table = (
465
+ '<table style="width:100%">'
466
+ '<thead><tr>'
467
+ '<th>Link</th><th>Tag</th>'
468
+ '<th style="text-align:right">Score</th>'
469
+ '<th>Category</th>'
470
+ '</tr></thead>'
471
+ '<tbody>' + "".join(rows) + '</tbody></table>'
472
+ )
473
+
474
+ csv = ", ".join(format_tag(t, _use_underscore) for t, _ in items)
475
+ return table, csv
476
+
477
+ def on_analyze(image, url):
478
+ if image is None and not url:
479
+ return *refresh_results(
480
+ top_k.value, min_score.value,
481
+ sort_by.value, use_underscore.value,
482
+ categories.value,
483
+ ), "⚠️ No image loaded."
484
+
485
+ pil = image
486
+ if pil is None and url:
487
+ import requests as std_requests
488
+ try:
489
+ resp = std_requests.get(url, timeout=30)
490
+ resp.raise_for_status()
491
+ tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
492
+ tmp.write(resp.content)
493
+ tmp.close()
494
+ pil = Image.open(tmp.name).convert("RGB")
495
+ except Exception as exc:
496
+ return "<i>Error loading URL.</i>", "", f"❌ {exc}"
497
+
498
+ state["current_image"] = pil
499
+ fn = state["predict_fn"]
500
+ if fn is None:
501
+ return "<i>No model loaded.</i>", "", "❌ No model loaded."
502
+
503
+ t0 = time.time()
504
+ all_logits = fn(pil)
505
+ state["all_logits"] = all_logits
506
+ state["tag_metadata"] = enrich_tags(all_logits)
507
+
508
+ table, csv = refresh_results(
509
+ top_k.value, min_score.value,
510
+ sort_by.value, use_underscore.value,
511
+ categories.value,
512
+ )
513
+ elapsed = time.time() - t0
514
+ n = len(state["tag_metadata"])
515
+ cached = _CACHE.size()
516
+ return table, csv, f"βœ… {n} tags Β· {cached} cached Β· {elapsed:.2f}s"
517
+
518
+ analyze_btn.click(
519
+ fn=on_analyze,
520
+ inputs=[image_input, url_input],
521
+ outputs=[tag_table, tag_string, status],
522
+ )
523
+
524
+ url_input.submit(
525
+ fn=on_analyze,
526
+ inputs=[image_input, url_input],
527
+ outputs=[tag_table, tag_string, status],
528
+ )
529
+
530
+ def on_clear():
531
+ state["all_logits"] = None
532
+ state["tag_metadata"] = None
533
+ state["current_image"] = None
534
+ return None, "", "<i>No results yet.</i>", "Cleared.", "", ""
535
+
536
+ clear_btn.click(
537
+ fn=on_clear,
538
+ inputs=[],
539
+ outputs=[image_input, url_input, tag_table, tag_string,
540
+ status, tag_query, tag_query_output],
541
+ )
542
+
543
+ def on_clear_cache():
544
+ _CACHE.clear()
545
+ return "🧹 Cache cleared (0 entries)"
546
+
547
+ clear_cache_btn.click(fn=on_clear_cache, inputs=[], outputs=[status])
548
+
549
+ for widget in [top_k, min_score, sort_by, use_underscore, categories]:
550
+ widget.change(
551
+ fn=refresh_results,
552
+ inputs=[top_k, min_score, sort_by, use_underscore, categories],
553
+ outputs=[tag_table, tag_string],
554
+ )
555
+
556
+ def query_tag_score(query):
557
+ meta = state.get("tag_metadata")
558
+ if not meta or not query:
559
+ return ""
560
+ query_l = query.lower()
561
+ matches = sorted(
562
+ [(t, meta[t]["score"]) for t in meta if query_l in t.lower()],
563
+ key=lambda x: x[1], reverse=True,
564
+ )[:20]
565
+ if not matches:
566
+ return "<i>No matching tags.</i>"
567
+ rows = "".join(
568
+ f"<tr><td>{t}</td><td>{s:.4f}</td>"
569
+ f"<td><code>{meta[t]['category_name'] or '?'}</code></td></tr>"
570
+ for t, s in matches
571
+ )
572
+ return (f"<table style='width:100%'>"
573
+ f"<tr><th>Tag</th><th>Score</th><th>Category</th></tr>"
574
+ f"{rows}</table>")
575
+
576
+ tag_query.change(
577
+ fn=query_tag_score,
578
+ inputs=[tag_query],
579
+ outputs=[tag_query_output],
580
+ )
581
+
582
+ copy_btn.click(
583
+ fn=lambda: None,
584
+ inputs=[],
585
+ outputs=[],
586
+ js="""() => {
587
+ const tb = document.querySelector('#csv-text textarea');
588
+ if (tb) { navigator.clipboard.writeText(tb.value); }
589
+ }"""
590
+ )
591
+
592
+ # ── model switcher ────────────────────────────────────────────────
593
+
594
+ def on_model_change(variant):
595
+ if not variant:
596
+ return "⚠️ No model selected"
597
+ try:
598
+ onnx_path = download_model_variant(variant)
599
+ predictor = Predictor(onnx_path)
600
+ state["predictor"] = predictor
601
+
602
+ def new_predict_fn(image: Image.Image) -> list[tuple[str, float]]:
603
+ tensor = preprocess(image, predictor.image_size)
604
+ logits = predictor.run(tensor)[0]
605
+ inv = {v: k for k, v in predictor.tag_to_id.items()}
606
+ indices = np.argsort(logits)[::-1]
607
+ return [(inv[int(i)], float(logits[i])) for i in indices]
608
+
609
+ state["predict_fn"] = new_predict_fn
610
+ state["all_logits"] = None
611
+ state["tag_metadata"] = None
612
+ _try_prebuild(predictor)
613
+ return f"βœ… Switched to {variant} ({predictor.num_classes} tags)"
614
+ except Exception as exc:
615
+ return f"❌ Failed to load model: {exc}"
616
+
617
+ model_dropdown.change(
618
+ fn=on_model_change,
619
+ inputs=[model_dropdown],
620
+ outputs=[model_status],
621
+ )
622
+
623
+ return app
624
+
625
+
626
+ # ── main ────────────────────────────────────────────────────────────────────
627
+
628
+ def _try_prebuild(predictor: Predictor):
629
+ try:
630
+ total, found = prebuild_cache(predictor.tag_to_id)
631
+ if total:
632
+ print(f"Cache prebuilt: {total} tags ({found} with category from HF dataset)")
633
+ except Exception as exc:
634
+ print(f"Cache prebuild skipped: {exc}")
635
+
636
+
637
+ def main():
638
+ model_arg = sys.argv[1] if len(sys.argv) > 1 else None
639
+ model_env = os.environ.get("MODEL_DIR")
640
+ model_variants: list[str] = []
641
+ initial_predict_fn = None
642
+
643
+ if model_arg:
644
+ onnx = Path(model_arg)
645
+ if not onnx.exists():
646
+ print(f"Error: {onnx} not found", file=sys.stderr)
647
+ sys.exit(1)
648
+ print(f"Loading local model: {onnx}")
649
+ predictor = Predictor(onnx)
650
+ _try_prebuild(predictor)
651
+
652
+ def _predict(image: Image.Image) -> list[tuple[str, float]]:
653
+ tensor = preprocess(image, predictor.image_size)
654
+ logits = predictor.run(tensor)[0]
655
+ inv = {v: k for k, v in predictor.tag_to_id.items()}
656
+ indices = np.argsort(logits)[::-1]
657
+ return [(inv[int(i)], float(logits[i])) for i in indices]
658
+
659
+ initial_predict_fn = _predict
660
+ elif model_env:
661
+ env_dir = Path(model_env)
662
+ if not env_dir.is_dir():
663
+ print(f"Error: MODEL_DIR {env_dir} is not a directory", file=sys.stderr)
664
+ sys.exit(1)
665
+ onnx_files = list(env_dir.glob("*.onnx"))
666
+ if not onnx_files:
667
+ print(f"Error: no .onnx files in {env_dir}", file=sys.stderr)
668
+ sys.exit(1)
669
+ onnx = onnx_files[0]
670
+ print(f"Loading local model from MODEL_DIR: {onnx}")
671
+ predictor = Predictor(onnx)
672
+ _try_prebuild(predictor)
673
+
674
+ def _predict(image: Image.Image) -> list[tuple[str, float]]:
675
+ tensor = preprocess(image, predictor.image_size)
676
+ logits = predictor.run(tensor)[0]
677
+ inv = {v: k for k, v in predictor.tag_to_id.items()}
678
+ indices = np.argsort(logits)[::-1]
679
+ return [(inv[int(i)], float(logits[i])) for i in indices]
680
+
681
+ initial_predict_fn = _predict
682
+ else:
683
+ print("Discovering model variants on HF hub …")
684
+ model_variants = discover_model_variants()
685
+ if not model_variants:
686
+ print("Warning: no models found on hub.")
687
+ else:
688
+ print(f"Found variants: {model_variants}")
689
+ default = model_variants[0]
690
+ print(f"Downloading default model: {default} …")
691
+ try:
692
+ onnx_path = download_model_variant(default)
693
+ predictor = Predictor(onnx_path)
694
+ _try_prebuild(predictor)
695
+
696
+ def _predict(image: Image.Image) -> list[tuple[str, float]]:
697
+ tensor = preprocess(image, predictor.image_size)
698
+ logits = predictor.run(tensor)[0]
699
+ inv = {v: k for k, v in predictor.tag_to_id.items()}
700
+ indices = np.argsort(logits)[::-1]
701
+ return [(inv[int(i)], float(logits[i])) for i in indices]
702
+
703
+ initial_predict_fn = _predict
704
+ print(f"Loaded {default} ({predictor.num_classes} tags)")
705
+ except Exception as exc:
706
+ print(f"Error loading default model: {exc}")
707
+
708
+ app = build_app(initial_predict_fn, model_variants)
709
+
710
+ host = os.environ.get("GRADIO_SERVER_NAME") or os.environ.get("HOST")
711
+ port_str = os.environ.get("GRADIO_SERVER_PORT") or os.environ.get("PORT")
712
+ port = int(port_str) if port_str else None
713
+
714
+ app.launch(server_name=host, server_port=port)
715
+
716
+
717
+ if __name__ == "__main__":
718
+ main()
requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ onnxruntime-gpu==1.18.0
2
+ gradio>=5
3
+ numpy
4
+ Pillow
5
+ huggingface-hub
6
+ datasets
7
+ tqdm