Dinamush Cursor commited on
Commit ·
f68a2b5
1
Parent(s): d4bed75
Add multi-model tagging and SHUCK3R-style classifier UX.
Browse filesPersist WD/ML tagger choice, harden selected-tag assignment with a noise floor, and restyle the SPA for clearer settings, run progress, and review.
Co-authored-by: Cursor <cursoragent@cursor.com>
- README.md +10 -3
- backend/app/api.py +224 -92
- backend/app/main.py +4 -23
- backend/app/providers.py +184 -0
- backend/app/schemas.py +11 -0
- backend/app/services.py +186 -79
- backend/app/storage.py +7 -0
- backend/requirements.txt +1 -0
- backend/tests/test_api_run_progress.py +122 -8
- backend/tests/test_services.py +72 -0
- frontend/index.html +7 -1
- frontend/src/App.jsx +556 -174
- frontend/src/api.js +33 -2
- frontend/src/styles.css +352 -21
README.md
CHANGED
|
@@ -105,16 +105,23 @@ The frontend automatically falls back to local mock mode when backend requests f
|
|
| 105 |
|
| 106 |
## GPU Acceleration
|
| 107 |
|
| 108 |
-
Inference
|
| 109 |
|
| 110 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
|
| 112 |
```bash
|
| 113 |
curl http://127.0.0.1:8000/health/providers
|
| 114 |
curl http://127.0.0.1:8000/api/providers
|
| 115 |
```
|
| 116 |
|
| 117 |
-
`likely_device: "gpu"`
|
| 118 |
|
| 119 |
### Runtime controls
|
| 120 |
|
|
|
|
| 105 |
|
| 106 |
## GPU Acceleration
|
| 107 |
|
| 108 |
+
Inference uses ONNX Runtime CUDA when the pip CUDA/cuDNN wheels are installed and discoverable.
|
| 109 |
|
| 110 |
+
```bash
|
| 111 |
+
pip install -r backend/requirements.txt
|
| 112 |
+
# includes: onnxruntime-gpu[cuda,cudnn]==1.26.0
|
| 113 |
+
```
|
| 114 |
+
|
| 115 |
+
On Windows the API prepends `site-packages/nvidia/*/bin` to the DLL search path before creating sessions. Without that, ORT may *list* CUDA then fall back to CPU on the first Conv.
|
| 116 |
+
|
| 117 |
+
### Verify GPU is actually usable
|
| 118 |
|
| 119 |
```bash
|
| 120 |
curl http://127.0.0.1:8000/health/providers
|
| 121 |
curl http://127.0.0.1:8000/api/providers
|
| 122 |
```
|
| 123 |
|
| 124 |
+
Look for `"cuda_usable": true`, `"likely_device": "gpu"`, and `active_providers` containing `CUDAExecutionProvider`. Listing CUDA alone is not enough.
|
| 125 |
|
| 126 |
### Runtime controls
|
| 127 |
|
backend/app/api.py
CHANGED
|
@@ -31,13 +31,16 @@ from .services import (
|
|
| 31 |
extract_scores,
|
| 32 |
extract_scores_batch,
|
| 33 |
extract_scores_with_experimental_media,
|
|
|
|
| 34 |
is_experimental_media,
|
| 35 |
load_known_tags,
|
| 36 |
migrate_file,
|
|
|
|
| 37 |
resolve_settings,
|
| 38 |
sanitize_folder_name,
|
| 39 |
scan_images,
|
| 40 |
)
|
|
|
|
| 41 |
from .storage import execute, fetch_all, fetch_one, from_json, to_json
|
| 42 |
|
| 43 |
router = APIRouter(prefix="/api")
|
|
@@ -67,11 +70,17 @@ class _ImageInferenceResult:
|
|
| 67 |
inference_failed: bool
|
| 68 |
|
| 69 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
def _now_iso() -> str:
|
| 71 |
return datetime.now(timezone.utc).isoformat()
|
| 72 |
|
| 73 |
|
| 74 |
-
def _get_max_inference_workers() -> int:
|
|
|
|
|
|
|
| 75 |
raw = os.getenv("MAX_INFERENCE_WORKERS", "2").strip()
|
| 76 |
try:
|
| 77 |
value = int(raw)
|
|
@@ -81,16 +90,19 @@ def _get_max_inference_workers() -> int:
|
|
| 81 |
|
| 82 |
|
| 83 |
def _get_inference_mode() -> str:
|
| 84 |
-
raw = os.getenv("INFERENCE_MODE", "
|
| 85 |
return "single" if raw == "single" else "batch"
|
| 86 |
|
| 87 |
|
| 88 |
-
def _get_inference_batch_size() -> int:
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
| 90 |
try:
|
| 91 |
value = int(raw)
|
| 92 |
except ValueError:
|
| 93 |
-
value =
|
| 94 |
return max(1, min(value, 64))
|
| 95 |
|
| 96 |
|
|
@@ -127,37 +139,82 @@ def _is_provider_related_error(err: Exception) -> bool:
|
|
| 127 |
return any(k in msg for k in keywords)
|
| 128 |
|
| 129 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
def _infer_one_image(
|
| 131 |
image_path: Path,
|
| 132 |
matched_tags: set[str],
|
| 133 |
confidence_threshold: float,
|
| 134 |
experimental_media_enabled: bool = False,
|
|
|
|
|
|
|
| 135 |
) -> _ImageInferenceResult:
|
| 136 |
try:
|
| 137 |
if experimental_media_enabled and is_experimental_media(image_path):
|
| 138 |
-
scores = extract_scores_with_experimental_media(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
else:
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
needs_review = True
|
| 147 |
-
reason = "No matching tags found in selected folders."
|
| 148 |
-
elif primary_score is not None and primary_score < confidence_threshold:
|
| 149 |
-
needs_review = True
|
| 150 |
-
reason = f"Below threshold ({primary_score:.3f} < {confidence_threshold:.3f})."
|
| 151 |
-
return _ImageInferenceResult(
|
| 152 |
-
image_path=image_path,
|
| 153 |
-
scores=scores,
|
| 154 |
-
primary_tag=primary_tag,
|
| 155 |
-
primary_score=primary_score,
|
| 156 |
-
secondary=secondary,
|
| 157 |
-
needs_review=needs_review,
|
| 158 |
-
reason=reason,
|
| 159 |
-
inference_failed=False,
|
| 160 |
-
)
|
| 161 |
except Exception as err:
|
| 162 |
if _is_provider_related_error(err):
|
| 163 |
logger.exception("inference_provider_failure image=%s", image_path)
|
|
@@ -181,6 +238,8 @@ def _infer_batch_with_fallback(
|
|
| 181 |
confidence_threshold: float,
|
| 182 |
requested_mode: str,
|
| 183 |
experimental_media_enabled: bool = False,
|
|
|
|
|
|
|
| 184 |
) -> tuple[list[_ImageInferenceResult], float, str]:
|
| 185 |
if not image_paths:
|
| 186 |
return [], 0.0, "none"
|
|
@@ -189,7 +248,12 @@ def _infer_batch_with_fallback(
|
|
| 189 |
start = time.perf_counter()
|
| 190 |
rows = [
|
| 191 |
_infer_one_image(
|
| 192 |
-
image_paths[0],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
)
|
| 194 |
]
|
| 195 |
elapsed_ms = (time.perf_counter() - start) * 1000.0
|
|
@@ -200,7 +264,14 @@ def _infer_batch_with_fallback(
|
|
| 200 |
if any(is_experimental_media(p) for p in image_paths):
|
| 201 |
start = time.perf_counter()
|
| 202 |
rows = [
|
| 203 |
-
_infer_one_image(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
for p in image_paths
|
| 205 |
]
|
| 206 |
elapsed_ms = (time.perf_counter() - start) * 1000.0
|
|
@@ -208,32 +279,17 @@ def _infer_batch_with_fallback(
|
|
| 208 |
|
| 209 |
start = time.perf_counter()
|
| 210 |
try:
|
| 211 |
-
scores_by_image = extract_scores_batch(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
if len(scores_by_image) != len(image_paths):
|
| 213 |
raise RuntimeError("Batch inference result count mismatch")
|
| 214 |
-
rows
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
reason = None
|
| 219 |
-
if primary_tag is None:
|
| 220 |
-
needs_review = True
|
| 221 |
-
reason = "No matching tags found in selected folders."
|
| 222 |
-
elif primary_score is not None and primary_score < confidence_threshold:
|
| 223 |
-
needs_review = True
|
| 224 |
-
reason = f"Below threshold ({primary_score:.3f} < {confidence_threshold:.3f})."
|
| 225 |
-
rows.append(
|
| 226 |
-
_ImageInferenceResult(
|
| 227 |
-
image_path=image_path,
|
| 228 |
-
scores=scores,
|
| 229 |
-
primary_tag=primary_tag,
|
| 230 |
-
primary_score=primary_score,
|
| 231 |
-
secondary=secondary,
|
| 232 |
-
needs_review=needs_review,
|
| 233 |
-
reason=reason,
|
| 234 |
-
inference_failed=False,
|
| 235 |
-
)
|
| 236 |
-
)
|
| 237 |
elapsed_ms = (time.perf_counter() - start) * 1000.0
|
| 238 |
return rows, elapsed_ms, "batch"
|
| 239 |
except Exception as err:
|
|
@@ -249,6 +305,8 @@ def _infer_batch_with_fallback(
|
|
| 249 |
confidence_threshold,
|
| 250 |
"batch",
|
| 251 |
experimental_media_enabled,
|
|
|
|
|
|
|
| 252 |
)
|
| 253 |
right_rows, right_ms, _ = _infer_batch_with_fallback(
|
| 254 |
image_paths[mid:],
|
|
@@ -256,10 +314,17 @@ def _infer_batch_with_fallback(
|
|
| 256 |
confidence_threshold,
|
| 257 |
"batch",
|
| 258 |
experimental_media_enabled,
|
|
|
|
|
|
|
| 259 |
)
|
| 260 |
return left_rows + right_rows, left_ms + right_ms, "batch_fallback"
|
| 261 |
row = _infer_one_image(
|
| 262 |
-
image_paths[0],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 263 |
)
|
| 264 |
elapsed_ms = (time.perf_counter() - start) * 1000.0
|
| 265 |
return [row], elapsed_ms, "single_fallback"
|
|
@@ -269,6 +334,14 @@ def _settings_from_db() -> AppSettings:
|
|
| 269 |
row = fetch_one("SELECT * FROM settings WHERE id = 1")
|
| 270 |
if not row:
|
| 271 |
return AppSettings()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 272 |
return AppSettings(
|
| 273 |
root_repo=row["root_repo"],
|
| 274 |
categories_root=row["categories_root"],
|
|
@@ -276,10 +349,30 @@ def _settings_from_db() -> AppSettings:
|
|
| 276 |
default_migrate_mode=row["default_migrate_mode"],
|
| 277 |
scan_recursive=bool(row.get("scan_recursive", 1)),
|
| 278 |
experimental_media_enabled=bool(row.get("experimental_media_enabled", 0)),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 279 |
)
|
| 280 |
|
| 281 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
def _item_from_row(row: dict, include_full_scores: bool = False) -> ClassifiedItem:
|
|
|
|
|
|
|
|
|
|
| 283 |
return ClassifiedItem(
|
| 284 |
id=row["id"],
|
| 285 |
run_id=row["run_id"],
|
|
@@ -288,11 +381,8 @@ def _item_from_row(row: dict, include_full_scores: bool = False) -> ClassifiedIt
|
|
| 288 |
primary_tag=row["primary_tag"],
|
| 289 |
primary_score=row["primary_score"],
|
| 290 |
secondary_suggestions=from_json(row.get("secondary_json") or "[]", default=[]),
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
if include_full_scores
|
| 294 |
-
else None
|
| 295 |
-
),
|
| 296 |
suggested_destination=row["suggested_destination"],
|
| 297 |
final_tag=row["final_tag"],
|
| 298 |
final_destination=row["final_destination"],
|
|
@@ -326,6 +416,7 @@ def _run_status_from_row(row: dict) -> RunStatusResponse:
|
|
| 326 |
batch_size=telemetry.get("batch_size"),
|
| 327 |
avg_infer_ms_per_image=telemetry.get("avg_infer_ms_per_image"),
|
| 328 |
queue_seed=telemetry.get("queue_seed"),
|
|
|
|
| 329 |
)
|
| 330 |
|
| 331 |
|
|
@@ -349,8 +440,14 @@ def _execute_run(
|
|
| 349 |
matched_tags: set[str],
|
| 350 |
scan_recursive: bool = True,
|
| 351 |
experimental_media_enabled: bool = False,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 352 |
) -> None:
|
| 353 |
try:
|
|
|
|
|
|
|
| 354 |
execute(
|
| 355 |
"UPDATE runs SET status = 'running', started_at = ?, last_error = NULL WHERE id = ?",
|
| 356 |
(_now_iso(), run_id),
|
|
@@ -380,7 +477,8 @@ def _execute_run(
|
|
| 380 |
rng = random.Random(queue_seed)
|
| 381 |
rng.shuffle(ordered_paths)
|
| 382 |
inference_mode = _get_inference_mode()
|
| 383 |
-
configured_batch_size = _get_inference_batch_size()
|
|
|
|
| 384 |
batch_size = 1 if inference_mode == "single" else configured_batch_size
|
| 385 |
_set_run_telemetry(
|
| 386 |
run_id,
|
|
@@ -403,12 +501,13 @@ def _execute_run(
|
|
| 403 |
cancelled = False
|
| 404 |
infer_elapsed_ms_total = 0.0
|
| 405 |
infer_sample_count = 0
|
| 406 |
-
max_workers = _get_max_inference_workers()
|
| 407 |
logger.info(
|
| 408 |
-
"run_inference_workers run_id=%d workers=%d total_images=%d",
|
| 409 |
run_id,
|
| 410 |
max_workers,
|
| 411 |
scan_output.stats.eligible_images,
|
|
|
|
| 412 |
)
|
| 413 |
|
| 414 |
with ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="infer") as executor:
|
|
@@ -432,6 +531,8 @@ def _execute_run(
|
|
| 432 |
confidence_threshold,
|
| 433 |
inference_mode,
|
| 434 |
experimental_media_enabled,
|
|
|
|
|
|
|
| 435 |
)
|
| 436 |
pending[future] = next_batch
|
| 437 |
|
|
@@ -544,12 +645,25 @@ def get_settings() -> AppSettings:
|
|
| 544 |
|
| 545 |
@router.put("/settings", response_model=AppSettings)
|
| 546 |
def save_settings(payload: SaveSettingsRequest) -> AppSettings:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 547 |
try:
|
| 548 |
execute(
|
| 549 |
"""
|
| 550 |
UPDATE settings
|
| 551 |
SET root_repo = ?, categories_root = ?, confidence_threshold = ?,
|
| 552 |
-
default_migrate_mode = ?, scan_recursive = ?, experimental_media_enabled = ?
|
|
|
|
|
|
|
| 553 |
WHERE id = 1
|
| 554 |
""",
|
| 555 |
(
|
|
@@ -559,11 +673,18 @@ def save_settings(payload: SaveSettingsRequest) -> AppSettings:
|
|
| 559 |
payload.default_migrate_mode,
|
| 560 |
1 if payload.scan_recursive else 0,
|
| 561 |
1 if payload.experimental_media_enabled else 0,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 562 |
),
|
| 563 |
)
|
| 564 |
except Exception:
|
| 565 |
logger.exception("failed to save settings")
|
| 566 |
raise HTTPException(status_code=500, detail="Failed to persist settings")
|
|
|
|
| 567 |
return payload
|
| 568 |
|
| 569 |
|
|
@@ -606,34 +727,22 @@ def scan_preview() -> dict:
|
|
| 606 |
|
| 607 |
@router.get("/providers")
|
| 608 |
def get_providers() -> dict[str, object]:
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
"cpu_available": "CPUExecutionProvider" in providers,
|
| 619 |
-
"forced_cpu": force_cpu,
|
| 620 |
-
"likely_device": "cpu" if force_cpu else ("gpu" if cuda_available else "cpu"),
|
| 621 |
-
}
|
| 622 |
-
except Exception as err:
|
| 623 |
-
return {
|
| 624 |
-
"available_providers": [],
|
| 625 |
-
"cuda_available": False,
|
| 626 |
-
"cpu_available": True,
|
| 627 |
-
"forced_cpu": force_cpu,
|
| 628 |
-
"likely_device": "cpu",
|
| 629 |
-
"error": str(err),
|
| 630 |
-
}
|
| 631 |
|
| 632 |
|
| 633 |
@router.post("/runs/start", response_model=StartRunResponse)
|
| 634 |
def start_run(payload: StartRunRequest) -> StartRunResponse:
|
| 635 |
logger.info("run_start_requested")
|
| 636 |
current = _settings_from_db()
|
|
|
|
| 637 |
resolved = resolve_settings(
|
| 638 |
current, payload.root_repo, payload.categories_root, payload.confidence_threshold
|
| 639 |
)
|
|
@@ -643,12 +752,19 @@ def start_run(payload: StartRunRequest) -> StartRunResponse:
|
|
| 643 |
if not resolved.root_repo or not resolved.categories_root:
|
| 644 |
raise HTTPException(status_code=400, detail="root_repo and categories_root are required")
|
| 645 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 646 |
try:
|
| 647 |
known_tags = load_known_tags(TAGS_CSV)
|
| 648 |
-
mappings = discover_tag_folders(categories_root, known_tags,
|
| 649 |
matched_tags = {m.matched_tag for m in mappings if m.matched and m.matched_tag}
|
| 650 |
if not matched_tags:
|
| 651 |
-
raise HTTPException(
|
|
|
|
|
|
|
|
|
|
| 652 |
except HTTPException:
|
| 653 |
raise
|
| 654 |
except Exception:
|
|
@@ -660,10 +776,15 @@ def start_run(payload: StartRunRequest) -> StartRunResponse:
|
|
| 660 |
"""
|
| 661 |
INSERT INTO runs (
|
| 662 |
root_repo, categories_root, confidence_threshold, status,
|
| 663 |
-
total_images, processed_images, failed_images, cancel_requested
|
| 664 |
-
) VALUES (?, ?, ?, 'pending', 0, 0, 0, 0)
|
| 665 |
""",
|
| 666 |
-
(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 667 |
)
|
| 668 |
except Exception:
|
| 669 |
logger.exception("failed to create run row")
|
|
@@ -671,8 +792,19 @@ def start_run(payload: StartRunRequest) -> StartRunResponse:
|
|
| 671 |
|
| 672 |
worker = threading.Thread(
|
| 673 |
target=_execute_run,
|
| 674 |
-
args=(
|
| 675 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 676 |
daemon=True,
|
| 677 |
)
|
| 678 |
worker.start()
|
|
|
|
| 31 |
extract_scores,
|
| 32 |
extract_scores_batch,
|
| 33 |
extract_scores_with_experimental_media,
|
| 34 |
+
global_top_tags,
|
| 35 |
is_experimental_media,
|
| 36 |
load_known_tags,
|
| 37 |
migrate_file,
|
| 38 |
+
normalize_tag_name,
|
| 39 |
resolve_settings,
|
| 40 |
sanitize_folder_name,
|
| 41 |
scan_images,
|
| 42 |
)
|
| 43 |
+
from .providers import clear_provider_probe_cache, probe_execution_providers
|
| 44 |
from .storage import execute, fetch_all, fetch_one, from_json, to_json
|
| 45 |
|
| 46 |
router = APIRouter(prefix="/api")
|
|
|
|
| 70 |
inference_failed: bool
|
| 71 |
|
| 72 |
|
| 73 |
+
def _assignment_noise_floor(confidence_threshold: float) -> float:
|
| 74 |
+
return max(0.15, float(confidence_threshold) * 0.5)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
def _now_iso() -> str:
|
| 78 |
return datetime.now(timezone.utc).isoformat()
|
| 79 |
|
| 80 |
|
| 81 |
+
def _get_max_inference_workers(settings_workers: int | None = None) -> int:
|
| 82 |
+
if settings_workers is not None:
|
| 83 |
+
return max(1, min(int(settings_workers), 16))
|
| 84 |
raw = os.getenv("MAX_INFERENCE_WORKERS", "2").strip()
|
| 85 |
try:
|
| 86 |
value = int(raw)
|
|
|
|
| 90 |
|
| 91 |
|
| 92 |
def _get_inference_mode() -> str:
|
| 93 |
+
raw = os.getenv("INFERENCE_MODE", "single").strip().lower()
|
| 94 |
return "single" if raw == "single" else "batch"
|
| 95 |
|
| 96 |
|
| 97 |
+
def _get_inference_batch_size(settings_batch: int | None = None) -> int:
|
| 98 |
+
# Default 1: imgutils cannot true-batch; small batches keep workers saturated.
|
| 99 |
+
if settings_batch is not None:
|
| 100 |
+
return max(1, min(int(settings_batch), 64))
|
| 101 |
+
raw = os.getenv("INFERENCE_BATCH_SIZE", "1").strip()
|
| 102 |
try:
|
| 103 |
value = int(raw)
|
| 104 |
except ValueError:
|
| 105 |
+
value = 1
|
| 106 |
return max(1, min(value, 64))
|
| 107 |
|
| 108 |
|
|
|
|
| 139 |
return any(k in msg for k in keywords)
|
| 140 |
|
| 141 |
|
| 142 |
+
def _classify_from_scores(
|
| 143 |
+
image_path: Path,
|
| 144 |
+
scores: dict[str, float],
|
| 145 |
+
matched_tags: set[str],
|
| 146 |
+
confidence_threshold: float,
|
| 147 |
+
) -> _ImageInferenceResult:
|
| 148 |
+
primary_tag, primary_score, secondary = choose_best_tags(scores, matched_tags)
|
| 149 |
+
needs_review = False
|
| 150 |
+
reason = None
|
| 151 |
+
if not scores:
|
| 152 |
+
return _ImageInferenceResult(
|
| 153 |
+
image_path=image_path,
|
| 154 |
+
scores=scores,
|
| 155 |
+
primary_tag=None,
|
| 156 |
+
primary_score=None,
|
| 157 |
+
secondary=[],
|
| 158 |
+
needs_review=True,
|
| 159 |
+
reason="Inference returned no tag scores for this image.",
|
| 160 |
+
inference_failed=True,
|
| 161 |
+
)
|
| 162 |
+
if primary_tag is None:
|
| 163 |
+
needs_review = True
|
| 164 |
+
reason = "No matching tags found among selected tags."
|
| 165 |
+
elif primary_score is not None:
|
| 166 |
+
noise_floor = _assignment_noise_floor(confidence_threshold)
|
| 167 |
+
if primary_score < noise_floor:
|
| 168 |
+
needs_review = True
|
| 169 |
+
reason = (
|
| 170 |
+
f"Below noise floor ({primary_score:.3f} < {noise_floor:.3f}); "
|
| 171 |
+
"no reliable selected-tag match."
|
| 172 |
+
)
|
| 173 |
+
secondary = [{"tag": primary_tag, "score": float(primary_score)}, *secondary][:4]
|
| 174 |
+
primary_tag = None
|
| 175 |
+
primary_score = None
|
| 176 |
+
elif primary_score < confidence_threshold:
|
| 177 |
+
needs_review = True
|
| 178 |
+
reason = f"Below threshold ({primary_score:.3f} < {confidence_threshold:.3f})."
|
| 179 |
+
# Weak selected-tag winners are suggestions only — do not present as the label.
|
| 180 |
+
secondary = [{"tag": primary_tag, "score": float(primary_score)}, *secondary][:4]
|
| 181 |
+
primary_tag = None
|
| 182 |
+
primary_score = None
|
| 183 |
+
return _ImageInferenceResult(
|
| 184 |
+
image_path=image_path,
|
| 185 |
+
scores=scores,
|
| 186 |
+
primary_tag=primary_tag,
|
| 187 |
+
primary_score=primary_score,
|
| 188 |
+
secondary=secondary,
|
| 189 |
+
needs_review=needs_review,
|
| 190 |
+
reason=reason,
|
| 191 |
+
inference_failed=False,
|
| 192 |
+
)
|
| 193 |
+
|
| 194 |
+
|
| 195 |
def _infer_one_image(
|
| 196 |
image_path: Path,
|
| 197 |
matched_tags: set[str],
|
| 198 |
confidence_threshold: float,
|
| 199 |
experimental_media_enabled: bool = False,
|
| 200 |
+
tagger_model: str = "wd_swinv2_v3",
|
| 201 |
+
wd_general_threshold: float = 0.35,
|
| 202 |
) -> _ImageInferenceResult:
|
| 203 |
try:
|
| 204 |
if experimental_media_enabled and is_experimental_media(image_path):
|
| 205 |
+
scores = extract_scores_with_experimental_media(
|
| 206 |
+
image_path,
|
| 207 |
+
experimental_media_enabled,
|
| 208 |
+
tagger_model=tagger_model,
|
| 209 |
+
wd_general_threshold=wd_general_threshold,
|
| 210 |
+
)
|
| 211 |
else:
|
| 212 |
+
scores = extract_scores(
|
| 213 |
+
image_path,
|
| 214 |
+
tagger_model=tagger_model,
|
| 215 |
+
wd_general_threshold=wd_general_threshold,
|
| 216 |
+
)
|
| 217 |
+
return _classify_from_scores(image_path, scores, matched_tags, confidence_threshold)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
except Exception as err:
|
| 219 |
if _is_provider_related_error(err):
|
| 220 |
logger.exception("inference_provider_failure image=%s", image_path)
|
|
|
|
| 238 |
confidence_threshold: float,
|
| 239 |
requested_mode: str,
|
| 240 |
experimental_media_enabled: bool = False,
|
| 241 |
+
tagger_model: str = "wd_swinv2_v3",
|
| 242 |
+
wd_general_threshold: float = 0.35,
|
| 243 |
) -> tuple[list[_ImageInferenceResult], float, str]:
|
| 244 |
if not image_paths:
|
| 245 |
return [], 0.0, "none"
|
|
|
|
| 248 |
start = time.perf_counter()
|
| 249 |
rows = [
|
| 250 |
_infer_one_image(
|
| 251 |
+
image_paths[0],
|
| 252 |
+
matched_tags,
|
| 253 |
+
confidence_threshold,
|
| 254 |
+
experimental_media_enabled,
|
| 255 |
+
tagger_model,
|
| 256 |
+
wd_general_threshold,
|
| 257 |
)
|
| 258 |
]
|
| 259 |
elapsed_ms = (time.perf_counter() - start) * 1000.0
|
|
|
|
| 264 |
if any(is_experimental_media(p) for p in image_paths):
|
| 265 |
start = time.perf_counter()
|
| 266 |
rows = [
|
| 267 |
+
_infer_one_image(
|
| 268 |
+
p,
|
| 269 |
+
matched_tags,
|
| 270 |
+
confidence_threshold,
|
| 271 |
+
experimental_media_enabled,
|
| 272 |
+
tagger_model,
|
| 273 |
+
wd_general_threshold,
|
| 274 |
+
)
|
| 275 |
for p in image_paths
|
| 276 |
]
|
| 277 |
elapsed_ms = (time.perf_counter() - start) * 1000.0
|
|
|
|
| 279 |
|
| 280 |
start = time.perf_counter()
|
| 281 |
try:
|
| 282 |
+
scores_by_image = extract_scores_batch(
|
| 283 |
+
image_paths,
|
| 284 |
+
tagger_model=tagger_model,
|
| 285 |
+
wd_general_threshold=wd_general_threshold,
|
| 286 |
+
)
|
| 287 |
if len(scores_by_image) != len(image_paths):
|
| 288 |
raise RuntimeError("Batch inference result count mismatch")
|
| 289 |
+
rows = [
|
| 290 |
+
_classify_from_scores(image_path, scores, matched_tags, confidence_threshold)
|
| 291 |
+
for image_path, scores in zip(image_paths, scores_by_image)
|
| 292 |
+
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 293 |
elapsed_ms = (time.perf_counter() - start) * 1000.0
|
| 294 |
return rows, elapsed_ms, "batch"
|
| 295 |
except Exception as err:
|
|
|
|
| 305 |
confidence_threshold,
|
| 306 |
"batch",
|
| 307 |
experimental_media_enabled,
|
| 308 |
+
tagger_model,
|
| 309 |
+
wd_general_threshold,
|
| 310 |
)
|
| 311 |
right_rows, right_ms, _ = _infer_batch_with_fallback(
|
| 312 |
image_paths[mid:],
|
|
|
|
| 314 |
confidence_threshold,
|
| 315 |
"batch",
|
| 316 |
experimental_media_enabled,
|
| 317 |
+
tagger_model,
|
| 318 |
+
wd_general_threshold,
|
| 319 |
)
|
| 320 |
return left_rows + right_rows, left_ms + right_ms, "batch_fallback"
|
| 321 |
row = _infer_one_image(
|
| 322 |
+
image_paths[0],
|
| 323 |
+
matched_tags,
|
| 324 |
+
confidence_threshold,
|
| 325 |
+
experimental_media_enabled,
|
| 326 |
+
tagger_model,
|
| 327 |
+
wd_general_threshold,
|
| 328 |
)
|
| 329 |
elapsed_ms = (time.perf_counter() - start) * 1000.0
|
| 330 |
return [row], elapsed_ms, "single_fallback"
|
|
|
|
| 334 |
row = fetch_one("SELECT * FROM settings WHERE id = 1")
|
| 335 |
if not row:
|
| 336 |
return AppSettings()
|
| 337 |
+
selected_raw = row.get("selected_tags_json") or "[]"
|
| 338 |
+
selected_tags = from_json(selected_raw, default=[])
|
| 339 |
+
if not isinstance(selected_tags, list):
|
| 340 |
+
selected_tags = []
|
| 341 |
+
selected_tags = [str(t).strip() for t in selected_tags if str(t).strip()]
|
| 342 |
+
tagger_model = str(row.get("tagger_model") or "wd_swinv2_v3").strip()
|
| 343 |
+
if tagger_model not in {"ml_danbooru", "wd_swinv2_v3", "wd_eva02_large"}:
|
| 344 |
+
tagger_model = "wd_swinv2_v3"
|
| 345 |
return AppSettings(
|
| 346 |
root_repo=row["root_repo"],
|
| 347 |
categories_root=row["categories_root"],
|
|
|
|
| 349 |
default_migrate_mode=row["default_migrate_mode"],
|
| 350 |
scan_recursive=bool(row.get("scan_recursive", 1)),
|
| 351 |
experimental_media_enabled=bool(row.get("experimental_media_enabled", 0)),
|
| 352 |
+
selected_tags=selected_tags,
|
| 353 |
+
max_inference_workers=int(row.get("max_inference_workers") or 2),
|
| 354 |
+
inference_batch_size=int(row.get("inference_batch_size") or 1),
|
| 355 |
+
force_cpu_inference=bool(row.get("force_cpu_inference", 0)),
|
| 356 |
+
tagger_model=tagger_model, # type: ignore[arg-type]
|
| 357 |
+
wd_general_threshold=float(row.get("wd_general_threshold") or 0.35),
|
| 358 |
)
|
| 359 |
|
| 360 |
|
| 361 |
+
def _apply_runtime_inference_env(settings: AppSettings) -> None:
|
| 362 |
+
"""Mirror persisted settings into env knobs used by the run executor."""
|
| 363 |
+
os.environ["MAX_INFERENCE_WORKERS"] = str(settings.max_inference_workers)
|
| 364 |
+
os.environ["INFERENCE_BATCH_SIZE"] = str(settings.inference_batch_size)
|
| 365 |
+
if settings.force_cpu_inference:
|
| 366 |
+
os.environ["FORCE_CPU_INFERENCE"] = "true"
|
| 367 |
+
else:
|
| 368 |
+
os.environ.pop("FORCE_CPU_INFERENCE", None)
|
| 369 |
+
clear_provider_probe_cache()
|
| 370 |
+
|
| 371 |
+
|
| 372 |
def _item_from_row(row: dict, include_full_scores: bool = False) -> ClassifiedItem:
|
| 373 |
+
scores = from_json(row.get("full_scores_json") or "{}", default={})
|
| 374 |
+
if not isinstance(scores, dict):
|
| 375 |
+
scores = {}
|
| 376 |
return ClassifiedItem(
|
| 377 |
id=row["id"],
|
| 378 |
run_id=row["run_id"],
|
|
|
|
| 381 |
primary_tag=row["primary_tag"],
|
| 382 |
primary_score=row["primary_score"],
|
| 383 |
secondary_suggestions=from_json(row.get("secondary_json") or "[]", default=[]),
|
| 384 |
+
global_top_tags=global_top_tags({str(k): float(v) for k, v in scores.items()}),
|
| 385 |
+
full_scores=scores if include_full_scores else None,
|
|
|
|
|
|
|
|
|
|
| 386 |
suggested_destination=row["suggested_destination"],
|
| 387 |
final_tag=row["final_tag"],
|
| 388 |
final_destination=row["final_destination"],
|
|
|
|
| 416 |
batch_size=telemetry.get("batch_size"),
|
| 417 |
avg_infer_ms_per_image=telemetry.get("avg_infer_ms_per_image"),
|
| 418 |
queue_seed=telemetry.get("queue_seed"),
|
| 419 |
+
tagger_model=row.get("tagger_model"),
|
| 420 |
)
|
| 421 |
|
| 422 |
|
|
|
|
| 440 |
matched_tags: set[str],
|
| 441 |
scan_recursive: bool = True,
|
| 442 |
experimental_media_enabled: bool = False,
|
| 443 |
+
max_inference_workers: int = 2,
|
| 444 |
+
inference_batch_size: int = 1,
|
| 445 |
+
tagger_model: str = "wd_swinv2_v3",
|
| 446 |
+
wd_general_threshold: float = 0.35,
|
| 447 |
) -> None:
|
| 448 |
try:
|
| 449 |
+
provider_state = probe_execution_providers()
|
| 450 |
+
logger.info("run_provider_state run_id=%d state=%s", run_id, provider_state)
|
| 451 |
execute(
|
| 452 |
"UPDATE runs SET status = 'running', started_at = ?, last_error = NULL WHERE id = ?",
|
| 453 |
(_now_iso(), run_id),
|
|
|
|
| 477 |
rng = random.Random(queue_seed)
|
| 478 |
rng.shuffle(ordered_paths)
|
| 479 |
inference_mode = _get_inference_mode()
|
| 480 |
+
configured_batch_size = _get_inference_batch_size(inference_batch_size)
|
| 481 |
+
# Prefer single-image tasks so worker pool stays saturated (no true ORT batch).
|
| 482 |
batch_size = 1 if inference_mode == "single" else configured_batch_size
|
| 483 |
_set_run_telemetry(
|
| 484 |
run_id,
|
|
|
|
| 501 |
cancelled = False
|
| 502 |
infer_elapsed_ms_total = 0.0
|
| 503 |
infer_sample_count = 0
|
| 504 |
+
max_workers = _get_max_inference_workers(max_inference_workers)
|
| 505 |
logger.info(
|
| 506 |
+
"run_inference_workers run_id=%d workers=%d total_images=%d device=%s",
|
| 507 |
run_id,
|
| 508 |
max_workers,
|
| 509 |
scan_output.stats.eligible_images,
|
| 510 |
+
provider_state.get("likely_device"),
|
| 511 |
)
|
| 512 |
|
| 513 |
with ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="infer") as executor:
|
|
|
|
| 531 |
confidence_threshold,
|
| 532 |
inference_mode,
|
| 533 |
experimental_media_enabled,
|
| 534 |
+
tagger_model,
|
| 535 |
+
wd_general_threshold,
|
| 536 |
)
|
| 537 |
pending[future] = next_batch
|
| 538 |
|
|
|
|
| 645 |
|
| 646 |
@router.put("/settings", response_model=AppSettings)
|
| 647 |
def save_settings(payload: SaveSettingsRequest) -> AppSettings:
|
| 648 |
+
known = load_known_tags(TAGS_CSV)
|
| 649 |
+
known_by_norm = {normalize_tag_name(t): t for t in known}
|
| 650 |
+
cleaned_tags: list[str] = []
|
| 651 |
+
for tag in payload.selected_tags:
|
| 652 |
+
value = tag.strip()
|
| 653 |
+
if not value:
|
| 654 |
+
continue
|
| 655 |
+
matched = value if value in known else known_by_norm.get(normalize_tag_name(value))
|
| 656 |
+
if matched and matched not in cleaned_tags:
|
| 657 |
+
cleaned_tags.append(matched)
|
| 658 |
+
payload.selected_tags = cleaned_tags
|
| 659 |
try:
|
| 660 |
execute(
|
| 661 |
"""
|
| 662 |
UPDATE settings
|
| 663 |
SET root_repo = ?, categories_root = ?, confidence_threshold = ?,
|
| 664 |
+
default_migrate_mode = ?, scan_recursive = ?, experimental_media_enabled = ?,
|
| 665 |
+
selected_tags_json = ?, max_inference_workers = ?, inference_batch_size = ?,
|
| 666 |
+
force_cpu_inference = ?, tagger_model = ?, wd_general_threshold = ?
|
| 667 |
WHERE id = 1
|
| 668 |
""",
|
| 669 |
(
|
|
|
|
| 673 |
payload.default_migrate_mode,
|
| 674 |
1 if payload.scan_recursive else 0,
|
| 675 |
1 if payload.experimental_media_enabled else 0,
|
| 676 |
+
to_json(cleaned_tags),
|
| 677 |
+
int(payload.max_inference_workers),
|
| 678 |
+
int(payload.inference_batch_size),
|
| 679 |
+
1 if payload.force_cpu_inference else 0,
|
| 680 |
+
payload.tagger_model,
|
| 681 |
+
float(payload.wd_general_threshold),
|
| 682 |
),
|
| 683 |
)
|
| 684 |
except Exception:
|
| 685 |
logger.exception("failed to save settings")
|
| 686 |
raise HTTPException(status_code=500, detail="Failed to persist settings")
|
| 687 |
+
_apply_runtime_inference_env(payload)
|
| 688 |
return payload
|
| 689 |
|
| 690 |
|
|
|
|
| 727 |
|
| 728 |
@router.get("/providers")
|
| 729 |
def get_providers() -> dict[str, object]:
|
| 730 |
+
settings = _settings_from_db()
|
| 731 |
+
_apply_runtime_inference_env(settings)
|
| 732 |
+
info = probe_execution_providers()
|
| 733 |
+
info["tagger_model"] = settings.tagger_model
|
| 734 |
+
info["note"] = (
|
| 735 |
+
"CUDA usability reflects ORT GPU runtime readiness; "
|
| 736 |
+
"the active tagger model is selected separately in settings."
|
| 737 |
+
)
|
| 738 |
+
return info
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 739 |
|
| 740 |
|
| 741 |
@router.post("/runs/start", response_model=StartRunResponse)
|
| 742 |
def start_run(payload: StartRunRequest) -> StartRunResponse:
|
| 743 |
logger.info("run_start_requested")
|
| 744 |
current = _settings_from_db()
|
| 745 |
+
_apply_runtime_inference_env(current)
|
| 746 |
resolved = resolve_settings(
|
| 747 |
current, payload.root_repo, payload.categories_root, payload.confidence_threshold
|
| 748 |
)
|
|
|
|
| 752 |
if not resolved.root_repo or not resolved.categories_root:
|
| 753 |
raise HTTPException(status_code=400, detail="root_repo and categories_root are required")
|
| 754 |
|
| 755 |
+
selected_folders = payload.selected_folders
|
| 756 |
+
if not selected_folders:
|
| 757 |
+
selected_folders = list(current.selected_tags)
|
| 758 |
+
|
| 759 |
try:
|
| 760 |
known_tags = load_known_tags(TAGS_CSV)
|
| 761 |
+
mappings = discover_tag_folders(categories_root, known_tags, selected_folders)
|
| 762 |
matched_tags = {m.matched_tag for m in mappings if m.matched and m.matched_tag}
|
| 763 |
if not matched_tags:
|
| 764 |
+
raise HTTPException(
|
| 765 |
+
status_code=400,
|
| 766 |
+
detail="No selected tags map to known tags.csv entries. Save tags in settings first.",
|
| 767 |
+
)
|
| 768 |
except HTTPException:
|
| 769 |
raise
|
| 770 |
except Exception:
|
|
|
|
| 776 |
"""
|
| 777 |
INSERT INTO runs (
|
| 778 |
root_repo, categories_root, confidence_threshold, status,
|
| 779 |
+
total_images, processed_images, failed_images, cancel_requested, tagger_model
|
| 780 |
+
) VALUES (?, ?, ?, 'pending', 0, 0, 0, 0, ?)
|
| 781 |
""",
|
| 782 |
+
(
|
| 783 |
+
str(root_repo),
|
| 784 |
+
str(categories_root),
|
| 785 |
+
resolved.confidence_threshold,
|
| 786 |
+
current.tagger_model,
|
| 787 |
+
),
|
| 788 |
)
|
| 789 |
except Exception:
|
| 790 |
logger.exception("failed to create run row")
|
|
|
|
| 792 |
|
| 793 |
worker = threading.Thread(
|
| 794 |
target=_execute_run,
|
| 795 |
+
args=(
|
| 796 |
+
run_id,
|
| 797 |
+
root_repo,
|
| 798 |
+
categories_root,
|
| 799 |
+
resolved.confidence_threshold,
|
| 800 |
+
matched_tags,
|
| 801 |
+
resolved.scan_recursive,
|
| 802 |
+
resolved.experimental_media_enabled,
|
| 803 |
+
current.max_inference_workers,
|
| 804 |
+
current.inference_batch_size,
|
| 805 |
+
current.tagger_model,
|
| 806 |
+
current.wd_general_threshold,
|
| 807 |
+
),
|
| 808 |
daemon=True,
|
| 809 |
)
|
| 810 |
worker.start()
|
backend/app/main.py
CHANGED
|
@@ -1,7 +1,6 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import logging
|
| 4 |
-
import os
|
| 5 |
import time
|
| 6 |
|
| 7 |
from fastapi import FastAPI
|
|
@@ -9,6 +8,7 @@ from fastapi import Request
|
|
| 9 |
from fastapi.middleware.cors import CORSMiddleware
|
| 10 |
|
| 11 |
from .api import router
|
|
|
|
| 12 |
from .storage import init_db
|
| 13 |
|
| 14 |
app = FastAPI(title="Image Classifier Workflow API", version="0.1.0")
|
|
@@ -31,28 +31,7 @@ def _configure_logging() -> None:
|
|
| 31 |
|
| 32 |
|
| 33 |
def _get_provider_snapshot() -> dict[str, object]:
|
| 34 |
-
|
| 35 |
-
try:
|
| 36 |
-
import onnxruntime as ort
|
| 37 |
-
|
| 38 |
-
providers = list(ort.get_available_providers())
|
| 39 |
-
cuda_available = "CUDAExecutionProvider" in providers
|
| 40 |
-
return {
|
| 41 |
-
"available_providers": providers,
|
| 42 |
-
"cuda_available": cuda_available,
|
| 43 |
-
"cpu_available": "CPUExecutionProvider" in providers,
|
| 44 |
-
"forced_cpu": force_cpu,
|
| 45 |
-
"likely_device": "cpu" if force_cpu else ("gpu" if cuda_available else "cpu"),
|
| 46 |
-
}
|
| 47 |
-
except Exception as err:
|
| 48 |
-
return {
|
| 49 |
-
"available_providers": [],
|
| 50 |
-
"cuda_available": False,
|
| 51 |
-
"cpu_available": True,
|
| 52 |
-
"forced_cpu": force_cpu,
|
| 53 |
-
"likely_device": "cpu",
|
| 54 |
-
"error": str(err),
|
| 55 |
-
}
|
| 56 |
|
| 57 |
|
| 58 |
@app.middleware("http")
|
|
@@ -83,6 +62,8 @@ async def log_requests(request: Request, call_next):
|
|
| 83 |
@app.on_event("startup")
|
| 84 |
def startup() -> None:
|
| 85 |
_configure_logging()
|
|
|
|
|
|
|
| 86 |
provider_state = _get_provider_snapshot()
|
| 87 |
logger.info("onnx_provider_state state=%s", provider_state)
|
| 88 |
logger.info("initializing database at startup")
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import logging
|
|
|
|
| 4 |
import time
|
| 5 |
|
| 6 |
from fastapi import FastAPI
|
|
|
|
| 8 |
from fastapi.middleware.cors import CORSMiddleware
|
| 9 |
|
| 10 |
from .api import router
|
| 11 |
+
from .providers import clear_provider_probe_cache, preload_onnx_runtime_dlls, probe_execution_providers
|
| 12 |
from .storage import init_db
|
| 13 |
|
| 14 |
app = FastAPI(title="Image Classifier Workflow API", version="0.1.0")
|
|
|
|
| 31 |
|
| 32 |
|
| 33 |
def _get_provider_snapshot() -> dict[str, object]:
|
| 34 |
+
return probe_execution_providers()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
|
| 36 |
|
| 37 |
@app.middleware("http")
|
|
|
|
| 62 |
@app.on_event("startup")
|
| 63 |
def startup() -> None:
|
| 64 |
_configure_logging()
|
| 65 |
+
preload_onnx_runtime_dlls()
|
| 66 |
+
clear_provider_probe_cache()
|
| 67 |
provider_state = _get_provider_snapshot()
|
| 68 |
logger.info("onnx_provider_state state=%s", provider_state)
|
| 69 |
logger.info("initializing database at startup")
|
backend/app/providers.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
import os
|
| 5 |
+
import sys
|
| 6 |
+
from functools import lru_cache
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
_DLL_DIRS_CONFIGURED = False
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _nvidia_bin_dirs() -> list[Path]:
|
| 14 |
+
try:
|
| 15 |
+
import onnxruntime as ort
|
| 16 |
+
|
| 17 |
+
site_packages = Path(ort.__file__).resolve().parents[1]
|
| 18 |
+
except Exception:
|
| 19 |
+
site_packages = Path(sys.prefix) / "Lib" / "site-packages"
|
| 20 |
+
nvidia_root = site_packages / "nvidia"
|
| 21 |
+
if not nvidia_root.is_dir():
|
| 22 |
+
return []
|
| 23 |
+
return sorted(p for p in nvidia_root.glob("*/bin") if p.is_dir())
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def ensure_nvidia_dll_search_path() -> list[str]:
|
| 27 |
+
"""
|
| 28 |
+
Make pip-bundled CUDA/cuDNN DLLs discoverable on Windows.
|
| 29 |
+
|
| 30 |
+
ort.preload_dlls() alone is not always enough for cudnn_engines_* libs;
|
| 31 |
+
without PATH / add_dll_directory, Conv ops fall back to CPU at runtime.
|
| 32 |
+
"""
|
| 33 |
+
global _DLL_DIRS_CONFIGURED
|
| 34 |
+
bin_dirs = [str(p.resolve()) for p in _nvidia_bin_dirs()]
|
| 35 |
+
if not bin_dirs:
|
| 36 |
+
return []
|
| 37 |
+
|
| 38 |
+
path_parts = os.environ.get("PATH", "").split(os.pathsep) if os.environ.get("PATH") else []
|
| 39 |
+
# Prepend missing bins so the loader finds them first.
|
| 40 |
+
for d in reversed(bin_dirs):
|
| 41 |
+
if d not in path_parts:
|
| 42 |
+
path_parts.insert(0, d)
|
| 43 |
+
os.environ["PATH"] = os.pathsep.join(path_parts)
|
| 44 |
+
|
| 45 |
+
if hasattr(os, "add_dll_directory"):
|
| 46 |
+
for d in bin_dirs:
|
| 47 |
+
try:
|
| 48 |
+
os.add_dll_directory(d)
|
| 49 |
+
except (FileNotFoundError, OSError):
|
| 50 |
+
logger.warning("add_dll_directory failed for %s", d)
|
| 51 |
+
|
| 52 |
+
if not _DLL_DIRS_CONFIGURED:
|
| 53 |
+
logger.info("nvidia_dll_dirs configured count=%d dirs=%s", len(bin_dirs), bin_dirs)
|
| 54 |
+
_DLL_DIRS_CONFIGURED = True
|
| 55 |
+
return bin_dirs
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def preload_onnx_runtime_dlls() -> None:
|
| 59 |
+
"""Load pip-bundled CUDA/cuDNN DLLs before creating ORT sessions."""
|
| 60 |
+
ensure_nvidia_dll_search_path()
|
| 61 |
+
try:
|
| 62 |
+
import onnxruntime as ort
|
| 63 |
+
|
| 64 |
+
if hasattr(ort, "preload_dlls"):
|
| 65 |
+
ort.preload_dlls()
|
| 66 |
+
logger.info("onnxruntime preload_dlls completed")
|
| 67 |
+
except Exception:
|
| 68 |
+
logger.exception("onnxruntime preload_dlls failed")
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def _force_cpu() -> bool:
|
| 72 |
+
return os.getenv("FORCE_CPU_INFERENCE", "").strip().lower() in {"1", "true", "yes", "on"}
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _find_probe_model() -> Path | None:
|
| 76 |
+
hub = Path.home() / ".cache" / "huggingface" / "hub"
|
| 77 |
+
matches = sorted(hub.glob("models--deepghs--ml-danbooru-onnx/**/*.onnx"))
|
| 78 |
+
return matches[0] if matches else None
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
@lru_cache(maxsize=1)
|
| 82 |
+
def probe_execution_providers() -> dict[str, object]:
|
| 83 |
+
"""
|
| 84 |
+
Probe *active* providers by creating a real ORT session and running it.
|
| 85 |
+
|
| 86 |
+
Listing CUDAExecutionProvider is not enough — Windows often lists it even when
|
| 87 |
+
CUDA/cuDNN DLLs fail during Conv execution and ORT silently falls back to CPU.
|
| 88 |
+
|
| 89 |
+
Note: CUDA usability is independent of which tagger model is selected in settings
|
| 90 |
+
(ML-Danbooru vs WD14 variants); both share the same ORT runtime path.
|
| 91 |
+
"""
|
| 92 |
+
force_cpu = _force_cpu()
|
| 93 |
+
result: dict[str, object] = {
|
| 94 |
+
"available_providers": [],
|
| 95 |
+
"active_providers": [],
|
| 96 |
+
"cuda_available": False,
|
| 97 |
+
"cuda_usable": False,
|
| 98 |
+
"cpu_available": True,
|
| 99 |
+
"forced_cpu": force_cpu,
|
| 100 |
+
"likely_device": "cpu",
|
| 101 |
+
"provider_error": None,
|
| 102 |
+
"ort_version": None,
|
| 103 |
+
"nvidia_dll_dirs": [],
|
| 104 |
+
}
|
| 105 |
+
try:
|
| 106 |
+
import numpy as np
|
| 107 |
+
import onnxruntime as ort
|
| 108 |
+
|
| 109 |
+
result["nvidia_dll_dirs"] = ensure_nvidia_dll_search_path()
|
| 110 |
+
preload_onnx_runtime_dlls()
|
| 111 |
+
result["ort_version"] = ort.__version__
|
| 112 |
+
available = list(ort.get_available_providers())
|
| 113 |
+
result["available_providers"] = available
|
| 114 |
+
result["cuda_available"] = "CUDAExecutionProvider" in available
|
| 115 |
+
result["cpu_available"] = "CPUExecutionProvider" in available
|
| 116 |
+
|
| 117 |
+
if force_cpu:
|
| 118 |
+
result["likely_device"] = "cpu"
|
| 119 |
+
result["active_providers"] = ["CPUExecutionProvider"]
|
| 120 |
+
return result
|
| 121 |
+
|
| 122 |
+
model_path = _find_probe_model()
|
| 123 |
+
if model_path is None:
|
| 124 |
+
result["likely_device"] = "gpu" if result["cuda_available"] else "cpu"
|
| 125 |
+
result["provider_error"] = (
|
| 126 |
+
"No cached ML-Danbooru ONNX model found to verify CUDA; "
|
| 127 |
+
"run one tagging pass to download it."
|
| 128 |
+
)
|
| 129 |
+
return result
|
| 130 |
+
|
| 131 |
+
requested = (
|
| 132 |
+
["CUDAExecutionProvider", "CPUExecutionProvider"]
|
| 133 |
+
if result["cuda_available"]
|
| 134 |
+
else ["CPUExecutionProvider"]
|
| 135 |
+
)
|
| 136 |
+
session = ort.InferenceSession(str(model_path), providers=requested)
|
| 137 |
+
active = list(session.get_providers())
|
| 138 |
+
result["active_providers"] = active
|
| 139 |
+
|
| 140 |
+
# Execute a real forward pass — session creation can claim CUDA while Conv fails.
|
| 141 |
+
if "CUDAExecutionProvider" in active:
|
| 142 |
+
inputs = session.get_inputs()[0]
|
| 143 |
+
shape = []
|
| 144 |
+
for dim in inputs.shape:
|
| 145 |
+
if isinstance(dim, int) and dim > 0:
|
| 146 |
+
shape.append(dim)
|
| 147 |
+
else:
|
| 148 |
+
shape.append(1 if len(shape) == 0 else 448)
|
| 149 |
+
# Model expects NCHW float; use 1x3x448x448 when dynamic.
|
| 150 |
+
if len(shape) != 4:
|
| 151 |
+
shape = [1, 3, 448, 448]
|
| 152 |
+
feed = {inputs.name: np.zeros(shape, dtype=np.float32)}
|
| 153 |
+
try:
|
| 154 |
+
session.run(None, feed)
|
| 155 |
+
result["cuda_usable"] = True
|
| 156 |
+
except Exception as run_err:
|
| 157 |
+
result["cuda_usable"] = False
|
| 158 |
+
result["provider_error"] = (
|
| 159 |
+
"CUDAExecutionProvider loaded but inference failed "
|
| 160 |
+
f"(cuDNN/runtime). Falling back to CPU. Detail: {run_err}"
|
| 161 |
+
)
|
| 162 |
+
logger.warning("provider_probe_cuda_run_failed error=%s", run_err)
|
| 163 |
+
else:
|
| 164 |
+
result["cuda_usable"] = False
|
| 165 |
+
if result["cuda_available"]:
|
| 166 |
+
result["provider_error"] = (
|
| 167 |
+
"CUDAExecutionProvider is listed but failed to initialize; "
|
| 168 |
+
"sessions are using CPU. Ensure onnxruntime-gpu[cuda,cudnn] "
|
| 169 |
+
"DLLs are installed and restart the API."
|
| 170 |
+
)
|
| 171 |
+
|
| 172 |
+
result["likely_device"] = "gpu" if result["cuda_usable"] else "cpu"
|
| 173 |
+
if result["cuda_usable"]:
|
| 174 |
+
logger.info("provider_probe_ok active=%s model=%s", active, model_path)
|
| 175 |
+
return result
|
| 176 |
+
except Exception as err:
|
| 177 |
+
logger.exception("provider_probe_failed")
|
| 178 |
+
result["provider_error"] = str(err)
|
| 179 |
+
result["likely_device"] = "cpu"
|
| 180 |
+
return result
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def clear_provider_probe_cache() -> None:
|
| 184 |
+
probe_execution_providers.cache_clear()
|
backend/app/schemas.py
CHANGED
|
@@ -8,6 +8,7 @@ from pydantic import BaseModel, Field
|
|
| 8 |
ItemStatus = Literal["proposed", "reviewed", "approved", "rejected", "migrated"]
|
| 9 |
MigrateMode = Literal["move", "copy"]
|
| 10 |
RunLifecycleStatus = Literal["pending", "running", "completed", "failed", "cancelled"]
|
|
|
|
| 11 |
|
| 12 |
|
| 13 |
class AppSettings(BaseModel):
|
|
@@ -17,6 +18,14 @@ class AppSettings(BaseModel):
|
|
| 17 |
default_migrate_mode: MigrateMode = "copy"
|
| 18 |
scan_recursive: bool = True
|
| 19 |
experimental_media_enabled: bool = False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
|
| 22 |
class SaveSettingsRequest(AppSettings):
|
|
@@ -58,6 +67,7 @@ class ClassifiedItem(BaseModel):
|
|
| 58 |
primary_tag: str | None
|
| 59 |
primary_score: float | None
|
| 60 |
secondary_suggestions: list[SecondarySuggestion] = Field(default_factory=list)
|
|
|
|
| 61 |
full_scores: dict[str, float] | None = None
|
| 62 |
suggested_destination: str | None
|
| 63 |
final_tag: str | None
|
|
@@ -94,6 +104,7 @@ class RunStatusResponse(BaseModel):
|
|
| 94 |
batch_size: int | None = None
|
| 95 |
avg_infer_ms_per_image: float | None = None
|
| 96 |
queue_seed: int | None = None
|
|
|
|
| 97 |
|
| 98 |
|
| 99 |
class UpdateItemRequest(BaseModel):
|
|
|
|
| 8 |
ItemStatus = Literal["proposed", "reviewed", "approved", "rejected", "migrated"]
|
| 9 |
MigrateMode = Literal["move", "copy"]
|
| 10 |
RunLifecycleStatus = Literal["pending", "running", "completed", "failed", "cancelled"]
|
| 11 |
+
TaggerModel = Literal["ml_danbooru", "wd_swinv2_v3", "wd_eva02_large"]
|
| 12 |
|
| 13 |
|
| 14 |
class AppSettings(BaseModel):
|
|
|
|
| 18 |
default_migrate_mode: MigrateMode = "copy"
|
| 19 |
scan_recursive: bool = True
|
| 20 |
experimental_media_enabled: bool = False
|
| 21 |
+
# Shuck3r-style persisted preferences (survive reload / restart).
|
| 22 |
+
selected_tags: list[str] = Field(default_factory=list)
|
| 23 |
+
# Shared ORT session is serialized; >2 workers mostly queues behind the lock.
|
| 24 |
+
max_inference_workers: int = Field(default=2, ge=1, le=16)
|
| 25 |
+
inference_batch_size: int = Field(default=1, ge=1, le=64)
|
| 26 |
+
force_cpu_inference: bool = False
|
| 27 |
+
tagger_model: TaggerModel = "wd_swinv2_v3"
|
| 28 |
+
wd_general_threshold: float = Field(default=0.35, ge=0.0, le=1.0)
|
| 29 |
|
| 30 |
|
| 31 |
class SaveSettingsRequest(AppSettings):
|
|
|
|
| 67 |
primary_tag: str | None
|
| 68 |
primary_score: float | None
|
| 69 |
secondary_suggestions: list[SecondarySuggestion] = Field(default_factory=list)
|
| 70 |
+
global_top_tags: list[SecondarySuggestion] = Field(default_factory=list)
|
| 71 |
full_scores: dict[str, float] | None = None
|
| 72 |
suggested_destination: str | None
|
| 73 |
final_tag: str | None
|
|
|
|
| 104 |
batch_size: int | None = None
|
| 105 |
avg_infer_ms_per_image: float | None = None
|
| 106 |
queue_seed: int | None = None
|
| 107 |
+
tagger_model: str | None = None
|
| 108 |
|
| 109 |
|
| 110 |
class UpdateItemRequest(BaseModel):
|
backend/app/services.py
CHANGED
|
@@ -5,6 +5,7 @@ import logging
|
|
| 5 |
import subprocess
|
| 6 |
import shutil
|
| 7 |
import tempfile
|
|
|
|
| 8 |
from dataclasses import dataclass
|
| 9 |
from pathlib import Path
|
| 10 |
|
|
@@ -26,6 +27,9 @@ VIDEO_EXTENSIONS = {
|
|
| 26 |
logger = logging.getLogger(__name__)
|
| 27 |
_BATCH_INFERENCE_SUPPORTED: bool | None = None
|
| 28 |
_WINDOWS_FORBIDDEN_CHARS = set('<>:"/\\|?*')
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
|
| 31 |
def normalize_tag_name(value: str) -> str:
|
|
@@ -213,24 +217,21 @@ def scan_images(
|
|
| 213 |
)
|
| 214 |
|
| 215 |
|
| 216 |
-
|
| 217 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
|
| 219 |
-
raw = get_mldanbooru_tags(
|
| 220 |
-
str(image_path),
|
| 221 |
-
threshold=0.0,
|
| 222 |
-
size=448,
|
| 223 |
-
keep_ratio=True,
|
| 224 |
-
drop_overlap=False,
|
| 225 |
-
use_real_name=False,
|
| 226 |
-
)
|
| 227 |
|
|
|
|
| 228 |
scores: dict[str, float] = {}
|
| 229 |
if isinstance(raw, dict):
|
| 230 |
for tag, score in raw.items():
|
| 231 |
scores[str(tag)] = float(score)
|
| 232 |
return scores
|
| 233 |
-
|
| 234 |
if isinstance(raw, list):
|
| 235 |
for item in raw:
|
| 236 |
if isinstance(item, (list, tuple)) and len(item) >= 2:
|
|
@@ -238,46 +239,161 @@ def extract_scores(image_path: Path) -> dict[str, float]:
|
|
| 238 |
return scores
|
| 239 |
|
| 240 |
|
| 241 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
from imgutils.tagging import get_mldanbooru_tags
|
| 243 |
|
| 244 |
raw = get_mldanbooru_tags(
|
| 245 |
-
image,
|
| 246 |
threshold=0.0,
|
| 247 |
size=448,
|
| 248 |
keep_ratio=True,
|
| 249 |
drop_overlap=False,
|
| 250 |
use_real_name=False,
|
| 251 |
)
|
|
|
|
| 252 |
|
| 253 |
-
scores: dict[str, float] = {}
|
| 254 |
-
if isinstance(raw, dict):
|
| 255 |
-
for tag, score in raw.items():
|
| 256 |
-
scores[str(tag)] = float(score)
|
| 257 |
-
return scores
|
| 258 |
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 263 |
return scores
|
| 264 |
|
| 265 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
def extract_scores_with_experimental_media(
|
| 267 |
-
image_path: Path,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
) -> dict[str, float]:
|
| 269 |
"""
|
| 270 |
Experimental path: supports GIF/video by sampling a representative frame.
|
| 271 |
"""
|
| 272 |
if not experimental_media_enabled or not is_experimental_media(image_path):
|
| 273 |
-
return extract_scores(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 274 |
|
| 275 |
ext = image_path.suffix.lower()
|
| 276 |
if ext == ".gif":
|
| 277 |
with Image.open(image_path) as gif:
|
| 278 |
gif.seek(0)
|
| 279 |
frame = gif.convert("RGB")
|
| 280 |
-
return _extract_scores_from_pil_image(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 281 |
|
| 282 |
# Video path: extract first frame with ffmpeg to a temporary image.
|
| 283 |
with tempfile.TemporaryDirectory(prefix="media_frame_") as temp_dir:
|
|
@@ -297,58 +413,46 @@ def extract_scores_with_experimental_media(
|
|
| 297 |
if result.returncode != 0 or not frame_path.exists():
|
| 298 |
err = (result.stderr or result.stdout or "").strip()
|
| 299 |
raise RuntimeError(f"Video frame extraction failed: {err or 'ffmpeg unavailable'}")
|
| 300 |
-
return extract_scores(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 301 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 302 |
|
| 303 |
-
|
|
|
|
|
|
|
| 304 |
if not image_paths:
|
| 305 |
return []
|
| 306 |
-
global _BATCH_INFERENCE_SUPPORTED
|
| 307 |
-
|
| 308 |
-
if _BATCH_INFERENCE_SUPPORTED is False:
|
| 309 |
-
return [extract_scores(p) for p in image_paths]
|
| 310 |
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
)
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
if "Unknown image type" in str(err):
|
| 325 |
-
if _BATCH_INFERENCE_SUPPORTED is not False:
|
| 326 |
-
logger.warning("batch inference not supported by imgutils; using per-image fallback")
|
| 327 |
-
_BATCH_INFERENCE_SUPPORTED = False
|
| 328 |
-
return [extract_scores(p) for p in image_paths]
|
| 329 |
-
raise
|
| 330 |
-
|
| 331 |
-
parsed: list[dict[str, float]] = []
|
| 332 |
-
if isinstance(raw, list) and len(raw) == len(image_paths):
|
| 333 |
-
_BATCH_INFERENCE_SUPPORTED = True
|
| 334 |
-
for entry in raw:
|
| 335 |
-
if isinstance(entry, dict):
|
| 336 |
-
parsed.append({str(k): float(v) for k, v in entry.items()})
|
| 337 |
-
continue
|
| 338 |
-
if isinstance(entry, list):
|
| 339 |
-
scores: dict[str, float] = {}
|
| 340 |
-
for item in entry:
|
| 341 |
-
if isinstance(item, (list, tuple)) and len(item) >= 2:
|
| 342 |
-
scores[str(item[0])] = float(item[1])
|
| 343 |
-
parsed.append(scores)
|
| 344 |
-
continue
|
| 345 |
-
raise TypeError("Unexpected batch inference entry format")
|
| 346 |
-
return parsed
|
| 347 |
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
return [
|
| 352 |
|
| 353 |
|
| 354 |
def choose_best_tags(
|
|
@@ -423,13 +527,16 @@ def resolve_settings(
|
|
| 423 |
categories_root: str | None,
|
| 424 |
confidence_threshold: float | None,
|
| 425 |
) -> AppSettings:
|
| 426 |
-
return
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
|
|
|
|
|
|
|
|
|
| 435 |
)
|
|
|
|
| 5 |
import subprocess
|
| 6 |
import shutil
|
| 7 |
import tempfile
|
| 8 |
+
import threading
|
| 9 |
from dataclasses import dataclass
|
| 10 |
from pathlib import Path
|
| 11 |
|
|
|
|
| 27 |
logger = logging.getLogger(__name__)
|
| 28 |
_BATCH_INFERENCE_SUPPORTED: bool | None = None
|
| 29 |
_WINDOWS_FORBIDDEN_CHARS = set('<>:"/\\|?*')
|
| 30 |
+
# imgutils keeps one shared ORT session; concurrent Run() calls under GPU load
|
| 31 |
+
# corrupt outputs (empty score dicts) and thrash VRAM. Serialize model execution.
|
| 32 |
+
_INFERENCE_LOCK = threading.Lock()
|
| 33 |
|
| 34 |
|
| 35 |
def normalize_tag_name(value: str) -> str:
|
|
|
|
| 217 |
)
|
| 218 |
|
| 219 |
|
| 220 |
+
TAGGER_MODEL_ML = "ml_danbooru"
|
| 221 |
+
TAGGER_MODEL_WD_SWINV2 = "wd_swinv2_v3"
|
| 222 |
+
TAGGER_MODEL_WD_EVA02 = "wd_eva02_large"
|
| 223 |
+
WD_MODEL_NAMES = {
|
| 224 |
+
TAGGER_MODEL_WD_SWINV2: "SwinV2_v3",
|
| 225 |
+
TAGGER_MODEL_WD_EVA02: "EVA02_Large",
|
| 226 |
+
}
|
| 227 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
|
| 229 |
+
def _parse_mldanbooru_raw(raw: object) -> dict[str, float]:
|
| 230 |
scores: dict[str, float] = {}
|
| 231 |
if isinstance(raw, dict):
|
| 232 |
for tag, score in raw.items():
|
| 233 |
scores[str(tag)] = float(score)
|
| 234 |
return scores
|
|
|
|
| 235 |
if isinstance(raw, list):
|
| 236 |
for item in raw:
|
| 237 |
if isinstance(item, (list, tuple)) and len(item) >= 2:
|
|
|
|
| 239 |
return scores
|
| 240 |
|
| 241 |
|
| 242 |
+
def _normalize_score_tags(scores: dict[str, float]) -> dict[str, float]:
|
| 243 |
+
"""Normalize tag keys to underscore form so they match tags.csv / selected tags."""
|
| 244 |
+
normalized: dict[str, float] = {}
|
| 245 |
+
for tag, score in scores.items():
|
| 246 |
+
key = normalize_tag_name(str(tag))
|
| 247 |
+
if not key:
|
| 248 |
+
continue
|
| 249 |
+
current = normalized.get(key)
|
| 250 |
+
if current is None or float(score) > current:
|
| 251 |
+
normalized[key] = float(score)
|
| 252 |
+
return normalized
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
def _parse_wd14_raw(raw: object) -> dict[str, float]:
|
| 256 |
+
scores: dict[str, float] = {}
|
| 257 |
+
if isinstance(raw, dict):
|
| 258 |
+
for tag, score in raw.items():
|
| 259 |
+
scores[str(tag)] = float(score)
|
| 260 |
+
return _normalize_score_tags(scores)
|
| 261 |
+
if isinstance(raw, (list, tuple)):
|
| 262 |
+
# fmt tuple returns ordered parts; flatten dict parts only.
|
| 263 |
+
for part in raw:
|
| 264 |
+
if isinstance(part, dict):
|
| 265 |
+
for tag, score in part.items():
|
| 266 |
+
scores[str(tag)] = float(score)
|
| 267 |
+
return _normalize_score_tags(scores)
|
| 268 |
+
return {}
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
def _run_mldanbooru(image: Path | Image.Image | str) -> dict[str, float]:
|
| 272 |
from imgutils.tagging import get_mldanbooru_tags
|
| 273 |
|
| 274 |
raw = get_mldanbooru_tags(
|
| 275 |
+
image if isinstance(image, Image.Image) else str(image),
|
| 276 |
threshold=0.0,
|
| 277 |
size=448,
|
| 278 |
keep_ratio=True,
|
| 279 |
drop_overlap=False,
|
| 280 |
use_real_name=False,
|
| 281 |
)
|
| 282 |
+
return _normalize_score_tags(_parse_mldanbooru_raw(raw))
|
| 283 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 284 |
|
| 285 |
+
def _run_wd14(
|
| 286 |
+
image: Path | Image.Image | str,
|
| 287 |
+
*,
|
| 288 |
+
model_name: str,
|
| 289 |
+
general_threshold: float,
|
| 290 |
+
) -> dict[str, float]:
|
| 291 |
+
from imgutils.tagging import get_wd14_tags
|
| 292 |
+
|
| 293 |
+
raw = get_wd14_tags(
|
| 294 |
+
image if isinstance(image, Image.Image) else str(image),
|
| 295 |
+
model_name=model_name,
|
| 296 |
+
general_threshold=general_threshold,
|
| 297 |
+
no_underline=False,
|
| 298 |
+
drop_overlap=False,
|
| 299 |
+
fmt="general",
|
| 300 |
+
)
|
| 301 |
+
return _parse_wd14_raw(raw)
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
def extract_scores(
|
| 305 |
+
image_path: Path,
|
| 306 |
+
*,
|
| 307 |
+
tagger_model: str = TAGGER_MODEL_WD_SWINV2,
|
| 308 |
+
wd_general_threshold: float = 0.35,
|
| 309 |
+
) -> dict[str, float]:
|
| 310 |
+
from .providers import ensure_nvidia_dll_search_path, preload_onnx_runtime_dlls
|
| 311 |
+
|
| 312 |
+
ensure_nvidia_dll_search_path()
|
| 313 |
+
preload_onnx_runtime_dlls()
|
| 314 |
+
|
| 315 |
+
with _INFERENCE_LOCK:
|
| 316 |
+
scores = _extract_scores_unlocked(
|
| 317 |
+
image_path,
|
| 318 |
+
tagger_model=tagger_model,
|
| 319 |
+
wd_general_threshold=wd_general_threshold,
|
| 320 |
+
)
|
| 321 |
+
# One retry: concurrent/GPU glitches occasionally return an empty map.
|
| 322 |
+
if not scores:
|
| 323 |
+
logger.warning(
|
| 324 |
+
"empty_scores_retry path=%s tagger_model=%s", image_path, tagger_model
|
| 325 |
+
)
|
| 326 |
+
scores = _extract_scores_unlocked(
|
| 327 |
+
image_path,
|
| 328 |
+
tagger_model=tagger_model,
|
| 329 |
+
wd_general_threshold=wd_general_threshold,
|
| 330 |
+
)
|
| 331 |
return scores
|
| 332 |
|
| 333 |
|
| 334 |
+
def _extract_scores_unlocked(
|
| 335 |
+
image: Path | Image.Image | str,
|
| 336 |
+
*,
|
| 337 |
+
tagger_model: str,
|
| 338 |
+
wd_general_threshold: float,
|
| 339 |
+
) -> dict[str, float]:
|
| 340 |
+
if tagger_model == TAGGER_MODEL_ML:
|
| 341 |
+
return _run_mldanbooru(image)
|
| 342 |
+
wd_name = WD_MODEL_NAMES.get(tagger_model)
|
| 343 |
+
if wd_name is None:
|
| 344 |
+
raise ValueError(f"Unsupported tagger_model: {tagger_model}")
|
| 345 |
+
return _run_wd14(
|
| 346 |
+
image,
|
| 347 |
+
model_name=wd_name,
|
| 348 |
+
general_threshold=wd_general_threshold,
|
| 349 |
+
)
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
def _extract_scores_from_pil_image(
|
| 353 |
+
image: Image.Image,
|
| 354 |
+
*,
|
| 355 |
+
tagger_model: str = TAGGER_MODEL_WD_SWINV2,
|
| 356 |
+
wd_general_threshold: float = 0.35,
|
| 357 |
+
) -> dict[str, float]:
|
| 358 |
+
from .providers import ensure_nvidia_dll_search_path, preload_onnx_runtime_dlls
|
| 359 |
+
|
| 360 |
+
ensure_nvidia_dll_search_path()
|
| 361 |
+
preload_onnx_runtime_dlls()
|
| 362 |
+
with _INFERENCE_LOCK:
|
| 363 |
+
return _extract_scores_unlocked(
|
| 364 |
+
image,
|
| 365 |
+
tagger_model=tagger_model,
|
| 366 |
+
wd_general_threshold=wd_general_threshold,
|
| 367 |
+
)
|
| 368 |
+
|
| 369 |
+
|
| 370 |
def extract_scores_with_experimental_media(
|
| 371 |
+
image_path: Path,
|
| 372 |
+
experimental_media_enabled: bool = False,
|
| 373 |
+
*,
|
| 374 |
+
tagger_model: str = TAGGER_MODEL_WD_SWINV2,
|
| 375 |
+
wd_general_threshold: float = 0.35,
|
| 376 |
) -> dict[str, float]:
|
| 377 |
"""
|
| 378 |
Experimental path: supports GIF/video by sampling a representative frame.
|
| 379 |
"""
|
| 380 |
if not experimental_media_enabled or not is_experimental_media(image_path):
|
| 381 |
+
return extract_scores(
|
| 382 |
+
image_path,
|
| 383 |
+
tagger_model=tagger_model,
|
| 384 |
+
wd_general_threshold=wd_general_threshold,
|
| 385 |
+
)
|
| 386 |
|
| 387 |
ext = image_path.suffix.lower()
|
| 388 |
if ext == ".gif":
|
| 389 |
with Image.open(image_path) as gif:
|
| 390 |
gif.seek(0)
|
| 391 |
frame = gif.convert("RGB")
|
| 392 |
+
return _extract_scores_from_pil_image(
|
| 393 |
+
frame,
|
| 394 |
+
tagger_model=tagger_model,
|
| 395 |
+
wd_general_threshold=wd_general_threshold,
|
| 396 |
+
)
|
| 397 |
|
| 398 |
# Video path: extract first frame with ffmpeg to a temporary image.
|
| 399 |
with tempfile.TemporaryDirectory(prefix="media_frame_") as temp_dir:
|
|
|
|
| 413 |
if result.returncode != 0 or not frame_path.exists():
|
| 414 |
err = (result.stderr or result.stdout or "").strip()
|
| 415 |
raise RuntimeError(f"Video frame extraction failed: {err or 'ffmpeg unavailable'}")
|
| 416 |
+
return extract_scores(
|
| 417 |
+
frame_path,
|
| 418 |
+
tagger_model=tagger_model,
|
| 419 |
+
wd_general_threshold=wd_general_threshold,
|
| 420 |
+
)
|
| 421 |
+
|
| 422 |
|
| 423 |
+
def extract_scores_batch(
|
| 424 |
+
image_paths: list[Path],
|
| 425 |
+
*,
|
| 426 |
+
tagger_model: str = TAGGER_MODEL_WD_SWINV2,
|
| 427 |
+
wd_general_threshold: float = 0.35,
|
| 428 |
+
) -> list[dict[str, float]]:
|
| 429 |
+
"""
|
| 430 |
+
imgutils taggers do not accept image lists.
|
| 431 |
|
| 432 |
+
Fall back to sequential per-image scoring; the run executor provides
|
| 433 |
+
parallelism across workers (avoid nested pools on a shared ORT session).
|
| 434 |
+
"""
|
| 435 |
if not image_paths:
|
| 436 |
return []
|
|
|
|
|
|
|
|
|
|
|
|
|
| 437 |
|
| 438 |
+
global _BATCH_INFERENCE_SUPPORTED
|
| 439 |
+
if _BATCH_INFERENCE_SUPPORTED is not False:
|
| 440 |
+
logger.info("batch list API unsupported by imgutils; using per-image inference")
|
| 441 |
+
_BATCH_INFERENCE_SUPPORTED = False
|
| 442 |
+
|
| 443 |
+
return [
|
| 444 |
+
extract_scores(
|
| 445 |
+
path,
|
| 446 |
+
tagger_model=tagger_model,
|
| 447 |
+
wd_general_threshold=wd_general_threshold,
|
| 448 |
)
|
| 449 |
+
for path in image_paths
|
| 450 |
+
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 451 |
|
| 452 |
+
|
| 453 |
+
def global_top_tags(scores: dict[str, float], limit: int = 5) -> list[dict[str, float]]:
|
| 454 |
+
ranked = sorted(scores.items(), key=lambda item: item[1], reverse=True)[:limit]
|
| 455 |
+
return [{"tag": tag, "score": float(score)} for tag, score in ranked]
|
| 456 |
|
| 457 |
|
| 458 |
def choose_best_tags(
|
|
|
|
| 527 |
categories_root: str | None,
|
| 528 |
confidence_threshold: float | None,
|
| 529 |
) -> AppSettings:
|
| 530 |
+
return current.model_copy(
|
| 531 |
+
update={
|
| 532 |
+
"root_repo": root_repo if root_repo is not None else current.root_repo,
|
| 533 |
+
"categories_root": (
|
| 534 |
+
categories_root if categories_root is not None else current.categories_root
|
| 535 |
+
),
|
| 536 |
+
"confidence_threshold": (
|
| 537 |
+
confidence_threshold
|
| 538 |
+
if confidence_threshold is not None
|
| 539 |
+
else current.confidence_threshold
|
| 540 |
+
),
|
| 541 |
+
}
|
| 542 |
)
|
backend/app/storage.py
CHANGED
|
@@ -81,6 +81,12 @@ def _ensure_settings_columns(conn: sqlite3.Connection) -> None:
|
|
| 81 |
expected_columns = {
|
| 82 |
"scan_recursive": "INTEGER NOT NULL DEFAULT 1",
|
| 83 |
"experimental_media_enabled": "INTEGER NOT NULL DEFAULT 0",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
}
|
| 85 |
rows = conn.execute("PRAGMA table_info(settings)").fetchall()
|
| 86 |
existing = {row[1] for row in rows}
|
|
@@ -99,6 +105,7 @@ def _ensure_runs_columns(conn: sqlite3.Connection) -> None:
|
|
| 99 |
"finished_at": "TEXT",
|
| 100 |
"last_error": "TEXT",
|
| 101 |
"cancel_requested": "INTEGER NOT NULL DEFAULT 0",
|
|
|
|
| 102 |
}
|
| 103 |
rows = conn.execute("PRAGMA table_info(runs)").fetchall()
|
| 104 |
existing = {row[1] for row in rows}
|
|
|
|
| 81 |
expected_columns = {
|
| 82 |
"scan_recursive": "INTEGER NOT NULL DEFAULT 1",
|
| 83 |
"experimental_media_enabled": "INTEGER NOT NULL DEFAULT 0",
|
| 84 |
+
"selected_tags_json": "TEXT NOT NULL DEFAULT '[]'",
|
| 85 |
+
"max_inference_workers": "INTEGER NOT NULL DEFAULT 2",
|
| 86 |
+
"inference_batch_size": "INTEGER NOT NULL DEFAULT 1",
|
| 87 |
+
"force_cpu_inference": "INTEGER NOT NULL DEFAULT 0",
|
| 88 |
+
"tagger_model": "TEXT NOT NULL DEFAULT 'wd_swinv2_v3'",
|
| 89 |
+
"wd_general_threshold": "REAL NOT NULL DEFAULT 0.35",
|
| 90 |
}
|
| 91 |
rows = conn.execute("PRAGMA table_info(settings)").fetchall()
|
| 92 |
existing = {row[1] for row in rows}
|
|
|
|
| 105 |
"finished_at": "TEXT",
|
| 106 |
"last_error": "TEXT",
|
| 107 |
"cancel_requested": "INTEGER NOT NULL DEFAULT 0",
|
| 108 |
+
"tagger_model": "TEXT NOT NULL DEFAULT 'wd_swinv2_v3'",
|
| 109 |
}
|
| 110 |
rows = conn.execute("PRAGMA table_info(runs)").fetchall()
|
| 111 |
existing = {row[1] for row in rows}
|
backend/requirements.txt
CHANGED
|
@@ -2,4 +2,5 @@ fastapi==0.116.1
|
|
| 2 |
uvicorn==0.35.0
|
| 3 |
pydantic==2.11.7
|
| 4 |
dghs-imgutils==0.19.0
|
|
|
|
| 5 |
pytest==8.4.1
|
|
|
|
| 2 |
uvicorn==0.35.0
|
| 3 |
pydantic==2.11.7
|
| 4 |
dghs-imgutils==0.19.0
|
| 5 |
+
onnxruntime-gpu[cuda,cudnn]==1.26.0
|
| 6 |
pytest==8.4.1
|
backend/tests/test_api_run_progress.py
CHANGED
|
@@ -5,6 +5,7 @@ from pathlib import Path
|
|
| 5 |
|
| 6 |
from fastapi.testclient import TestClient
|
| 7 |
|
|
|
|
| 8 |
from app.main import app
|
| 9 |
from app.schemas import FolderMapping, ScanStats
|
| 10 |
from app.services import ScanOutput
|
|
@@ -20,6 +21,119 @@ def test_providers_endpoint_shape():
|
|
| 20 |
assert "likely_device" in payload
|
| 21 |
assert "cuda_available" in payload
|
| 22 |
assert "forced_cpu" in payload
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
|
| 25 |
def _wait_for_status(client: TestClient, run_id: int, terminal: set[str], timeout_s: float = 5.0):
|
|
@@ -58,7 +172,7 @@ def test_run_progress_reaches_completed(monkeypatch, tmp_path: Path):
|
|
| 58 |
)
|
| 59 |
|
| 60 |
monkeypatch.setattr("app.api.scan_images", fake_scan_images)
|
| 61 |
-
monkeypatch.setattr("app.api.extract_scores", lambda
|
| 62 |
monkeypatch.setattr("app.api.load_known_tags", lambda _p: {"1girl"})
|
| 63 |
monkeypatch.setattr(
|
| 64 |
"app.api.discover_tag_folders",
|
|
@@ -116,7 +230,7 @@ def test_item_preview_returns_image(monkeypatch, tmp_path: Path):
|
|
| 116 |
)
|
| 117 |
|
| 118 |
monkeypatch.setattr("app.api.scan_images", fake_scan_images)
|
| 119 |
-
monkeypatch.setattr("app.api.extract_scores", lambda
|
| 120 |
monkeypatch.setattr("app.api.load_known_tags", lambda _p: {"1girl"})
|
| 121 |
monkeypatch.setattr(
|
| 122 |
"app.api.discover_tag_folders",
|
|
@@ -177,7 +291,7 @@ def test_selected_tag_wins_when_global_top_not_selected(monkeypatch, tmp_path: P
|
|
| 177 |
monkeypatch.setattr("app.api.scan_images", fake_scan_images)
|
| 178 |
monkeypatch.setattr(
|
| 179 |
"app.api.extract_scores",
|
| 180 |
-
lambda
|
| 181 |
)
|
| 182 |
monkeypatch.setattr("app.api.load_known_tags", lambda _p: {"1girl", "monster_girl", "slime_girl"})
|
| 183 |
monkeypatch.setattr(
|
|
@@ -243,7 +357,7 @@ def test_item_scores_debug_endpoint(monkeypatch, tmp_path: Path):
|
|
| 243 |
)
|
| 244 |
|
| 245 |
monkeypatch.setattr("app.api.scan_images", fake_scan_images)
|
| 246 |
-
monkeypatch.setattr("app.api.extract_scores", lambda
|
| 247 |
monkeypatch.setattr("app.api.load_known_tags", lambda _p: {"1girl", "solo"})
|
| 248 |
monkeypatch.setattr(
|
| 249 |
"app.api.discover_tag_folders",
|
|
@@ -305,7 +419,7 @@ def test_run_cancel_sets_cancelled(monkeypatch, tmp_path: Path):
|
|
| 305 |
),
|
| 306 |
)
|
| 307 |
|
| 308 |
-
def slow_scores(_p):
|
| 309 |
time.sleep(0.03)
|
| 310 |
return {"1girl": 0.88}
|
| 311 |
|
|
@@ -377,7 +491,7 @@ def test_seeded_queue_shuffle_is_deterministic(monkeypatch, tmp_path: Path):
|
|
| 377 |
),
|
| 378 |
)
|
| 379 |
|
| 380 |
-
def record_scores(path: Path):
|
| 381 |
observed_order.append(path.name)
|
| 382 |
return {"1girl": 0.88}
|
| 383 |
|
|
@@ -465,12 +579,12 @@ def test_batch_mode_falls_back_to_single(monkeypatch, tmp_path: Path):
|
|
| 465 |
),
|
| 466 |
)
|
| 467 |
|
| 468 |
-
def fail_batch(_paths):
|
| 469 |
raise RuntimeError("synthetic batch failure")
|
| 470 |
|
| 471 |
monkeypatch.setattr("app.api.scan_images", fake_scan_images)
|
| 472 |
monkeypatch.setattr("app.api.extract_scores_batch", fail_batch)
|
| 473 |
-
monkeypatch.setattr("app.api.extract_scores", lambda
|
| 474 |
monkeypatch.setattr("app.api.load_known_tags", lambda _p: {"1girl"})
|
| 475 |
monkeypatch.setattr(
|
| 476 |
"app.api.discover_tag_folders",
|
|
|
|
| 5 |
|
| 6 |
from fastapi.testclient import TestClient
|
| 7 |
|
| 8 |
+
from app.api import _classify_from_scores
|
| 9 |
from app.main import app
|
| 10 |
from app.schemas import FolderMapping, ScanStats
|
| 11 |
from app.services import ScanOutput
|
|
|
|
| 21 |
assert "likely_device" in payload
|
| 22 |
assert "cuda_available" in payload
|
| 23 |
assert "forced_cpu" in payload
|
| 24 |
+
assert "tagger_model" in payload
|
| 25 |
+
assert "note" in payload
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def test_settings_round_trip_includes_tagger_model(tmp_path: Path):
|
| 29 |
+
with TestClient(app) as client:
|
| 30 |
+
payload = {
|
| 31 |
+
"root_repo": str(tmp_path / "root"),
|
| 32 |
+
"categories_root": str(tmp_path / "cats"),
|
| 33 |
+
"confidence_threshold": 0.55,
|
| 34 |
+
"default_migrate_mode": "copy",
|
| 35 |
+
"scan_recursive": True,
|
| 36 |
+
"experimental_media_enabled": False,
|
| 37 |
+
"selected_tags": [],
|
| 38 |
+
"max_inference_workers": 2,
|
| 39 |
+
"inference_batch_size": 1,
|
| 40 |
+
"force_cpu_inference": False,
|
| 41 |
+
"tagger_model": "wd_eva02_large",
|
| 42 |
+
"wd_general_threshold": 0.4,
|
| 43 |
+
}
|
| 44 |
+
put_resp = client.put("/api/settings", json=payload)
|
| 45 |
+
put_resp.raise_for_status()
|
| 46 |
+
saved = put_resp.json()
|
| 47 |
+
assert saved["tagger_model"] == "wd_eva02_large"
|
| 48 |
+
assert saved["wd_general_threshold"] == 0.4
|
| 49 |
+
get_resp = client.get("/api/settings")
|
| 50 |
+
get_resp.raise_for_status()
|
| 51 |
+
loaded = get_resp.json()
|
| 52 |
+
assert loaded["tagger_model"] == "wd_eva02_large"
|
| 53 |
+
assert loaded["wd_general_threshold"] == 0.4
|
| 54 |
+
assert loaded["max_inference_workers"] == 2
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def test_below_threshold_null_primary_and_global_top_tags(monkeypatch, tmp_path: Path):
|
| 58 |
+
root = tmp_path / "root_thresh"
|
| 59 |
+
cats = tmp_path / "cats_thresh"
|
| 60 |
+
root.mkdir()
|
| 61 |
+
cats.mkdir()
|
| 62 |
+
(cats / "loli").mkdir()
|
| 63 |
+
file_path = root / "weak.jpg"
|
| 64 |
+
file_path.write_text("fake", encoding="utf-8")
|
| 65 |
+
|
| 66 |
+
def fake_scan_images(_root, **kwargs):
|
| 67 |
+
return ScanOutput(
|
| 68 |
+
image_paths=[file_path],
|
| 69 |
+
stats=ScanStats(
|
| 70 |
+
total_files=1,
|
| 71 |
+
eligible_images=1,
|
| 72 |
+
ignored_unsupported=0,
|
| 73 |
+
ignored_gif=0,
|
| 74 |
+
failed_to_read=0,
|
| 75 |
+
),
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
monkeypatch.setattr("app.api.scan_images", fake_scan_images)
|
| 79 |
+
monkeypatch.setattr(
|
| 80 |
+
"app.api.extract_scores",
|
| 81 |
+
lambda *_a, **_k: {
|
| 82 |
+
"1girl": 0.97,
|
| 83 |
+
"solo": 0.9,
|
| 84 |
+
"loli": 0.27,
|
| 85 |
+
},
|
| 86 |
+
)
|
| 87 |
+
monkeypatch.setattr("app.api.load_known_tags", lambda _p: {"loli", "1girl", "solo"})
|
| 88 |
+
monkeypatch.setattr(
|
| 89 |
+
"app.api.discover_tag_folders",
|
| 90 |
+
lambda _root, _tags, _selected: [
|
| 91 |
+
FolderMapping(
|
| 92 |
+
folder_name="loli", normalized_name="loli", matched_tag="loli", matched=True
|
| 93 |
+
)
|
| 94 |
+
],
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
with TestClient(app) as client:
|
| 98 |
+
start_resp = client.post(
|
| 99 |
+
"/api/runs/start",
|
| 100 |
+
json={
|
| 101 |
+
"root_repo": str(root),
|
| 102 |
+
"categories_root": str(cats),
|
| 103 |
+
"confidence_threshold": 0.6,
|
| 104 |
+
"selected_folders": ["loli"],
|
| 105 |
+
},
|
| 106 |
+
)
|
| 107 |
+
start_resp.raise_for_status()
|
| 108 |
+
run_id = start_resp.json()["run_id"]
|
| 109 |
+
final = _wait_for_status(client, run_id, {"completed", "failed", "cancelled"})
|
| 110 |
+
assert final is not None
|
| 111 |
+
assert final["status"] == "completed"
|
| 112 |
+
assert final.get("tagger_model")
|
| 113 |
+
items_resp = client.get(f"/api/runs/{run_id}/items")
|
| 114 |
+
items_resp.raise_for_status()
|
| 115 |
+
items = items_resp.json()
|
| 116 |
+
assert len(items) == 1
|
| 117 |
+
item = items[0]
|
| 118 |
+
assert item["primary_tag"] is None
|
| 119 |
+
assert item["needs_review"] is True
|
| 120 |
+
assert item["secondary_suggestions"]
|
| 121 |
+
assert item["secondary_suggestions"][0]["tag"] == "loli"
|
| 122 |
+
tops = [t["tag"] for t in item["global_top_tags"]]
|
| 123 |
+
assert tops[:2] == ["1girl", "solo"]
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def test_classify_noise_floor_clears_weak_primary(tmp_path: Path):
|
| 127 |
+
result = _classify_from_scores(
|
| 128 |
+
tmp_path / "x.jpg",
|
| 129 |
+
{"loli": 0.2, "1girl": 0.95},
|
| 130 |
+
{"loli"},
|
| 131 |
+
confidence_threshold=0.6,
|
| 132 |
+
)
|
| 133 |
+
assert result.primary_tag is None
|
| 134 |
+
assert result.needs_review is True
|
| 135 |
+
assert "noise floor" in (result.reason or "").lower()
|
| 136 |
+
assert result.secondary[0]["tag"] == "loli"
|
| 137 |
|
| 138 |
|
| 139 |
def _wait_for_status(client: TestClient, run_id: int, terminal: set[str], timeout_s: float = 5.0):
|
|
|
|
| 172 |
)
|
| 173 |
|
| 174 |
monkeypatch.setattr("app.api.scan_images", fake_scan_images)
|
| 175 |
+
monkeypatch.setattr("app.api.extract_scores", lambda *_a, **_k: {"1girl": 0.91})
|
| 176 |
monkeypatch.setattr("app.api.load_known_tags", lambda _p: {"1girl"})
|
| 177 |
monkeypatch.setattr(
|
| 178 |
"app.api.discover_tag_folders",
|
|
|
|
| 230 |
)
|
| 231 |
|
| 232 |
monkeypatch.setattr("app.api.scan_images", fake_scan_images)
|
| 233 |
+
monkeypatch.setattr("app.api.extract_scores", lambda *_a, **_k: {"1girl": 0.92})
|
| 234 |
monkeypatch.setattr("app.api.load_known_tags", lambda _p: {"1girl"})
|
| 235 |
monkeypatch.setattr(
|
| 236 |
"app.api.discover_tag_folders",
|
|
|
|
| 291 |
monkeypatch.setattr("app.api.scan_images", fake_scan_images)
|
| 292 |
monkeypatch.setattr(
|
| 293 |
"app.api.extract_scores",
|
| 294 |
+
lambda *_a, **_k: {"1girl": 0.99, "monster_girl": 0.85, "slime_girl": 0.82},
|
| 295 |
)
|
| 296 |
monkeypatch.setattr("app.api.load_known_tags", lambda _p: {"1girl", "monster_girl", "slime_girl"})
|
| 297 |
monkeypatch.setattr(
|
|
|
|
| 357 |
)
|
| 358 |
|
| 359 |
monkeypatch.setattr("app.api.scan_images", fake_scan_images)
|
| 360 |
+
monkeypatch.setattr("app.api.extract_scores", lambda *_a, **_k: {"1girl": 0.92, "solo": 0.88})
|
| 361 |
monkeypatch.setattr("app.api.load_known_tags", lambda _p: {"1girl", "solo"})
|
| 362 |
monkeypatch.setattr(
|
| 363 |
"app.api.discover_tag_folders",
|
|
|
|
| 419 |
),
|
| 420 |
)
|
| 421 |
|
| 422 |
+
def slow_scores(_p, **_kwargs):
|
| 423 |
time.sleep(0.03)
|
| 424 |
return {"1girl": 0.88}
|
| 425 |
|
|
|
|
| 491 |
),
|
| 492 |
)
|
| 493 |
|
| 494 |
+
def record_scores(path: Path, **_kwargs):
|
| 495 |
observed_order.append(path.name)
|
| 496 |
return {"1girl": 0.88}
|
| 497 |
|
|
|
|
| 579 |
),
|
| 580 |
)
|
| 581 |
|
| 582 |
+
def fail_batch(_paths, **_kwargs):
|
| 583 |
raise RuntimeError("synthetic batch failure")
|
| 584 |
|
| 585 |
monkeypatch.setattr("app.api.scan_images", fake_scan_images)
|
| 586 |
monkeypatch.setattr("app.api.extract_scores_batch", fail_batch)
|
| 587 |
+
monkeypatch.setattr("app.api.extract_scores", lambda *_a, **_k: {"1girl": 0.9})
|
| 588 |
monkeypatch.setattr("app.api.load_known_tags", lambda _p: {"1girl"})
|
| 589 |
monkeypatch.setattr(
|
| 590 |
"app.api.discover_tag_folders",
|
backend/tests/test_services.py
CHANGED
|
@@ -3,9 +3,14 @@ from pathlib import Path
|
|
| 3 |
from PIL import Image
|
| 4 |
|
| 5 |
from app.services import (
|
|
|
|
|
|
|
|
|
|
| 6 |
choose_best_tags,
|
| 7 |
discover_tag_folders,
|
| 8 |
ensure_collision_free_destination,
|
|
|
|
|
|
|
| 9 |
migrate_file,
|
| 10 |
normalize_tag_name,
|
| 11 |
sanitize_folder_name,
|
|
@@ -169,6 +174,73 @@ def test_choose_best_tags_matches_normalized_selected_tags() -> None:
|
|
| 169 |
assert secondary == [{"tag": "slime_girl", "score": 0.84}]
|
| 170 |
|
| 171 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
def test_migrate_file_copy_with_collision_suffix(tmp_path: Path) -> None:
|
| 173 |
src = tmp_path / "sample.jpg"
|
| 174 |
src.write_text("abc", encoding="utf-8")
|
|
|
|
| 3 |
from PIL import Image
|
| 4 |
|
| 5 |
from app.services import (
|
| 6 |
+
TAGGER_MODEL_ML,
|
| 7 |
+
TAGGER_MODEL_WD_EVA02,
|
| 8 |
+
TAGGER_MODEL_WD_SWINV2,
|
| 9 |
choose_best_tags,
|
| 10 |
discover_tag_folders,
|
| 11 |
ensure_collision_free_destination,
|
| 12 |
+
extract_scores,
|
| 13 |
+
global_top_tags,
|
| 14 |
migrate_file,
|
| 15 |
normalize_tag_name,
|
| 16 |
sanitize_folder_name,
|
|
|
|
| 174 |
assert secondary == [{"tag": "slime_girl", "score": 0.84}]
|
| 175 |
|
| 176 |
|
| 177 |
+
def test_global_top_tags_orders_by_score() -> None:
|
| 178 |
+
tops = global_top_tags({"a": 0.1, "b": 0.9, "c": 0.5}, limit=2)
|
| 179 |
+
assert tops == [{"tag": "b", "score": 0.9}, {"tag": "c", "score": 0.5}]
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def test_extract_scores_routes_ml_danbooru(monkeypatch, tmp_path: Path) -> None:
|
| 183 |
+
import app.services as services
|
| 184 |
+
|
| 185 |
+
calls: list[str] = []
|
| 186 |
+
|
| 187 |
+
monkeypatch.setattr("app.providers.ensure_nvidia_dll_search_path", lambda: [])
|
| 188 |
+
monkeypatch.setattr("app.providers.preload_onnx_runtime_dlls", lambda: None)
|
| 189 |
+
monkeypatch.setattr(
|
| 190 |
+
services,
|
| 191 |
+
"_run_mldanbooru",
|
| 192 |
+
lambda _image: calls.append("ml") or {"1girl": 0.9, "black_hair": 0.8},
|
| 193 |
+
)
|
| 194 |
+
monkeypatch.setattr(
|
| 195 |
+
services,
|
| 196 |
+
"_run_wd14",
|
| 197 |
+
lambda *_a, **_k: calls.append("wd") or {"solo": 0.7},
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
img = tmp_path / "x.jpg"
|
| 201 |
+
img.write_text("x", encoding="utf-8")
|
| 202 |
+
scores = extract_scores(img, tagger_model=TAGGER_MODEL_ML)
|
| 203 |
+
assert calls == ["ml"]
|
| 204 |
+
assert scores["1girl"] == 0.9
|
| 205 |
+
assert scores["black_hair"] == 0.8
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def test_extract_scores_routes_wd_models(monkeypatch, tmp_path: Path) -> None:
|
| 209 |
+
import app.services as services
|
| 210 |
+
|
| 211 |
+
seen: list[tuple[str, float]] = []
|
| 212 |
+
|
| 213 |
+
def fake_wd(image, *, model_name: str, general_threshold: float):
|
| 214 |
+
seen.append((model_name, general_threshold))
|
| 215 |
+
return {"solo": 0.66, "long_hair": 0.55}
|
| 216 |
+
|
| 217 |
+
monkeypatch.setattr("app.providers.ensure_nvidia_dll_search_path", lambda: [])
|
| 218 |
+
monkeypatch.setattr("app.providers.preload_onnx_runtime_dlls", lambda: None)
|
| 219 |
+
monkeypatch.setattr(services, "_run_mldanbooru", lambda _image: {"nope": 1.0})
|
| 220 |
+
monkeypatch.setattr(services, "_run_wd14", fake_wd)
|
| 221 |
+
|
| 222 |
+
img = tmp_path / "y.jpg"
|
| 223 |
+
img.write_text("y", encoding="utf-8")
|
| 224 |
+
|
| 225 |
+
scores = extract_scores(
|
| 226 |
+
img, tagger_model=TAGGER_MODEL_WD_SWINV2, wd_general_threshold=0.41
|
| 227 |
+
)
|
| 228 |
+
assert seen[-1] == ("SwinV2_v3", 0.41)
|
| 229 |
+
assert scores["solo"] == 0.66
|
| 230 |
+
assert scores["long_hair"] == 0.55
|
| 231 |
+
|
| 232 |
+
extract_scores(img, tagger_model=TAGGER_MODEL_WD_EVA02, wd_general_threshold=0.2)
|
| 233 |
+
assert seen[-1] == ("EVA02_Large", 0.2)
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
def test_normalize_score_tags_collapses_space_forms() -> None:
|
| 237 |
+
from app.services import _normalize_score_tags
|
| 238 |
+
|
| 239 |
+
scores = _normalize_score_tags({"Black Hair": 0.8, "black_hair": 0.9, "solo": 0.5})
|
| 240 |
+
assert scores["black_hair"] == 0.9
|
| 241 |
+
assert scores["solo"] == 0.5
|
| 242 |
+
|
| 243 |
+
|
| 244 |
def test_migrate_file_copy_with_collision_suffix(tmp_path: Path) -> None:
|
| 245 |
src = tmp_path / "sample.jpg"
|
| 246 |
src.write_text("abc", encoding="utf-8")
|
frontend/index.html
CHANGED
|
@@ -3,7 +3,13 @@
|
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
-
<title>Image Classifier
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
</head>
|
| 8 |
<body>
|
| 9 |
<div id="root"></div>
|
|
|
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>Image Classifier</title>
|
| 7 |
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 8 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 9 |
+
<link
|
| 10 |
+
href="https://fonts.googleapis.com/css2?family=Figtree:wght@400;500;600;700&family=Fraunces:opsz,wght@9..144,500;9..144,700&display=swap"
|
| 11 |
+
rel="stylesheet"
|
| 12 |
+
/>
|
| 13 |
</head>
|
| 14 |
<body>
|
| 15 |
<div id="root"></div>
|
frontend/src/App.jsx
CHANGED
|
@@ -8,11 +8,51 @@ const DEFAULT_SETTINGS = {
|
|
| 8 |
default_migrate_mode: "copy",
|
| 9 |
scan_recursive: true,
|
| 10 |
experimental_media_enabled: false,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
const ACTIVE_RUN_STORAGE_KEY = "imageClassifierActiveRunId";
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
function App() {
|
| 15 |
const [settings, setSettings] = useState(DEFAULT_SETTINGS);
|
|
|
|
|
|
|
|
|
|
| 16 |
const [offlineMode, setOfflineMode] = useState(false);
|
| 17 |
const [runId, setRunId] = useState(null);
|
| 18 |
const [runStatus, setRunStatus] = useState(null);
|
|
@@ -31,6 +71,8 @@ function App() {
|
|
| 31 |
const [scoreDebugByItem, setScoreDebugByItem] = useState({});
|
| 32 |
const [scoreLoadingByItem, setScoreLoadingByItem] = useState({});
|
| 33 |
const [finalTagDrafts, setFinalTagDrafts] = useState({});
|
|
|
|
|
|
|
| 34 |
const [opsLoading, setOpsLoading] = useState({
|
| 35 |
saving: false,
|
| 36 |
startingRun: false,
|
|
@@ -40,6 +82,18 @@ function App() {
|
|
| 40 |
});
|
| 41 |
const finalTagTimersRef = useRef({});
|
| 42 |
const pollTimerRef = useRef(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
async function refreshItems(currentRunId) {
|
| 45 |
if (!currentRunId) return;
|
|
@@ -57,10 +111,17 @@ function App() {
|
|
| 57 |
}
|
| 58 |
|
| 59 |
useEffect(() => {
|
| 60 |
-
api
|
|
|
|
| 61 |
.then((data) => {
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
setMigrateMode(data.default_migrate_mode || "copy");
|
|
|
|
| 64 |
setOfflineMode(api.isOfflineMode());
|
| 65 |
})
|
| 66 |
.catch((err) => setError(err.message));
|
|
@@ -68,15 +129,46 @@ function App() {
|
|
| 68 |
if (storedRunId > 0) {
|
| 69 |
setRunId(storedRunId);
|
| 70 |
}
|
| 71 |
-
api
|
|
|
|
| 72 |
.then((info) => setProviderInfo(info))
|
| 73 |
.catch(() => setProviderInfo(null));
|
| 74 |
}, []);
|
| 75 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
useEffect(() => {
|
| 77 |
let cancelled = false;
|
| 78 |
const timer = setTimeout(() => {
|
| 79 |
-
api
|
|
|
|
| 80 |
.then((data) => {
|
| 81 |
if (cancelled) return;
|
| 82 |
setTagOptions(data.items || []);
|
|
@@ -155,14 +247,23 @@ function App() {
|
|
| 155 |
}, [runId]);
|
| 156 |
|
| 157 |
async function handleSaveSettings(e) {
|
| 158 |
-
e.preventDefault();
|
| 159 |
setLoading(true);
|
| 160 |
setOpsLoading((prev) => ({ ...prev, saving: true }));
|
| 161 |
setError("");
|
| 162 |
try {
|
| 163 |
-
const saved = await api.saveSettings(settings);
|
| 164 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
setOfflineMode(api.isOfflineMode());
|
|
|
|
|
|
|
| 166 |
} catch (err) {
|
| 167 |
setError(err.message);
|
| 168 |
} finally {
|
|
@@ -171,11 +272,31 @@ function App() {
|
|
| 171 |
}
|
| 172 |
}
|
| 173 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
async function handleStartRun() {
|
| 175 |
setLoading(true);
|
| 176 |
setOpsLoading((prev) => ({ ...prev, startingRun: true }));
|
| 177 |
setError("");
|
| 178 |
try {
|
|
|
|
|
|
|
|
|
|
| 179 |
const result = await api.startRun({
|
| 180 |
...settings,
|
| 181 |
selected_folders: selectedTags.length > 0 ? selectedTags : null,
|
|
@@ -191,6 +312,7 @@ function App() {
|
|
| 191 |
failed_images: 0,
|
| 192 |
progress_pct: 0,
|
| 193 |
cancel_requested: false,
|
|
|
|
| 194 |
});
|
| 195 |
} catch (err) {
|
| 196 |
setError(err.message);
|
|
@@ -315,13 +437,11 @@ function App() {
|
|
| 315 |
if (!value) return;
|
| 316 |
if (selectedTags.includes(value)) return;
|
| 317 |
|
| 318 |
-
// Fast path when current dropdown options already include the tag.
|
| 319 |
if (tagOptions.includes(value)) {
|
| 320 |
addSelectedTag(value);
|
| 321 |
return;
|
| 322 |
}
|
| 323 |
|
| 324 |
-
// Validate against backend tag index to avoid accidental typo tags.
|
| 325 |
const result = await api.getTags(value, 200);
|
| 326 |
if ((result.items || []).includes(value)) {
|
| 327 |
addSelectedTag(value);
|
|
@@ -349,95 +469,256 @@ function App() {
|
|
| 349 |
|
| 350 |
return (
|
| 351 |
<div className="container">
|
| 352 |
-
<
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 357 |
{offlineMode && (
|
| 358 |
<div className="error">
|
| 359 |
Backend is offline. Running in browser-only demo mode with local mock data.
|
| 360 |
</div>
|
| 361 |
)}
|
| 362 |
-
{providerInfo && !offlineMode && (
|
| 363 |
-
<div className="
|
| 364 |
-
<span>Inference device: {providerInfo.likely_device || "unknown"}</span>
|
| 365 |
-
<span>CUDA available: {String(Boolean(providerInfo.cuda_available))}</span>
|
| 366 |
-
<span>Forced CPU: {String(Boolean(providerInfo.forced_cpu))}</span>
|
| 367 |
-
</div>
|
| 368 |
)}
|
| 369 |
-
|
| 370 |
{error && <div className="error">{error}</div>}
|
| 371 |
|
| 372 |
-
<section className="
|
| 373 |
-
<h2>
|
| 374 |
-
<
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
<
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 431 |
</form>
|
| 432 |
</section>
|
| 433 |
|
| 434 |
-
<section className="
|
| 435 |
-
<h2>
|
|
|
|
|
|
|
|
|
|
| 436 |
<label>
|
| 437 |
Tag match search (from tags.csv)
|
| 438 |
<input
|
| 439 |
value={tagQuery}
|
| 440 |
onChange={(e) => setTagQuery(e.target.value)}
|
|
|
|
|
|
|
| 441 |
onKeyDown={async (e) => {
|
| 442 |
if (e.key === "Enter") {
|
| 443 |
e.preventDefault();
|
|
@@ -449,21 +730,40 @@ function App() {
|
|
| 449 |
}
|
| 450 |
}
|
| 451 |
}}
|
| 452 |
-
placeholder="Type to search tags..."
|
| 453 |
/>
|
| 454 |
-
|
| 455 |
-
<div className="actions">
|
| 456 |
-
<select defaultValue="" onChange={(e) => addSelectedTag(e.target.value)}>
|
| 457 |
-
<option value="" disabled>
|
| 458 |
-
Select matching tag
|
| 459 |
-
</option>
|
| 460 |
{tagOptions.map((tag) => (
|
| 461 |
-
<option key={tag} value={tag}>
|
| 462 |
-
{tag}
|
| 463 |
-
</option>
|
| 464 |
))}
|
| 465 |
-
</
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 466 |
<button
|
|
|
|
|
|
|
| 467 |
onClick={async () => {
|
| 468 |
setError("");
|
| 469 |
try {
|
|
@@ -475,77 +775,124 @@ function App() {
|
|
| 475 |
>
|
| 476 |
Add typed tag(s)
|
| 477 |
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 478 |
</div>
|
| 479 |
<div className="stats">
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 487 |
</div>
|
| 488 |
-
<button
|
| 489 |
-
disabled={
|
| 490 |
-
loading ||
|
| 491 |
-
opsLoading.startingRun ||
|
| 492 |
-
(runStatus && ["pending", "running"].includes(runStatus.status))
|
| 493 |
-
}
|
| 494 |
-
onClick={handleStartRun}
|
| 495 |
-
>
|
| 496 |
-
Start Run
|
| 497 |
-
</button>
|
| 498 |
</section>
|
| 499 |
|
| 500 |
-
{runId && (
|
| 501 |
-
<section className="
|
| 502 |
-
<h2>
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 506 |
<span>
|
| 507 |
-
|
| 508 |
-
{Number(runStatus.progress_pct || 0).toFixed(1)}%)
|
| 509 |
</span>
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
<span>
|
| 516 |
-
Avg infer ms/image: {Number(runStatus.avg_infer_ms_per_image).toFixed(1)}
|
| 517 |
-
</span>
|
| 518 |
-
) : null}
|
| 519 |
-
{runStatus.queue_seed !== null && runStatus.queue_seed !== undefined ? (
|
| 520 |
-
<span>Queue seed: {runStatus.queue_seed}</span>
|
| 521 |
-
) : null}
|
| 522 |
-
{runStatus.cancel_requested && <span>Cancel requested</span>}
|
| 523 |
-
</div>
|
| 524 |
-
)}
|
| 525 |
-
{runStatus && ["pending", "running"].includes(runStatus.status) && (
|
| 526 |
<div className="actions">
|
| 527 |
-
<button onClick={cancelActiveRun}>
|
|
|
|
|
|
|
| 528 |
</div>
|
| 529 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 530 |
<div className="stats">
|
| 531 |
-
<span>
|
| 532 |
-
<span>Needs
|
| 533 |
<span>Approved: {stats.approved}</span>
|
| 534 |
<span>Migrated: {stats.migrated}</span>
|
| 535 |
</div>
|
| 536 |
<div className="actions">
|
| 537 |
-
<button
|
| 538 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 539 |
</button>
|
| 540 |
-
<button
|
| 541 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 542 |
</button>
|
| 543 |
<select value={migrateMode} onChange={(e) => setMigrateMode(e.target.value)}>
|
| 544 |
<option value="copy">copy</option>
|
| 545 |
<option value="move">move</option>
|
| 546 |
</select>
|
| 547 |
-
<button disabled={opsLoading.migrating} onClick={migrateApproved}>
|
| 548 |
-
{opsLoading.migrating ? "Migrating
|
| 549 |
</button>
|
| 550 |
</div>
|
| 551 |
|
|
@@ -554,13 +901,13 @@ function App() {
|
|
| 554 |
<tr>
|
| 555 |
<th>Select</th>
|
| 556 |
<th>Image</th>
|
| 557 |
-
<th>Primary
|
| 558 |
-
<th>
|
| 559 |
-
<th>
|
| 560 |
<th>Status</th>
|
| 561 |
-
<th>Final
|
| 562 |
-
<th>Debug
|
| 563 |
-
<th>
|
| 564 |
</tr>
|
| 565 |
</thead>
|
| 566 |
<tbody>
|
|
@@ -596,14 +943,42 @@ function App() {
|
|
| 596 |
/>
|
| 597 |
) : null}
|
| 598 |
<div className="image-path">{item.relative_path || item.file_path || "-"}</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 599 |
</div>
|
| 600 |
</td>
|
| 601 |
-
<td>{item.primary_tag || "-"}</td>
|
| 602 |
-
<td>{item.primary_score?.toFixed(3) || "-"}</td>
|
| 603 |
<td>
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 607 |
</td>
|
| 608 |
<td>{item.status}</td>
|
| 609 |
<td>
|
|
@@ -613,7 +988,7 @@ function App() {
|
|
| 613 |
/>
|
| 614 |
</td>
|
| 615 |
<td>
|
| 616 |
-
<button onClick={() => toggleScoreDebug(item.id)}>
|
| 617 |
{expandedScoreRows[item.id] ? "Hide JSON" : "Show JSON"}
|
| 618 |
</button>
|
| 619 |
{expandedScoreRows[item.id] && (
|
|
@@ -625,30 +1000,37 @@ function App() {
|
|
| 625 |
)}
|
| 626 |
</td>
|
| 627 |
<td>
|
| 628 |
-
<
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
|
| 645 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 646 |
</td>
|
| 647 |
</tr>
|
| 648 |
))}
|
| 649 |
</tbody>
|
| 650 |
</table>
|
| 651 |
-
{runMeta && <pre>{JSON.stringify(runMeta.counts, null, 2)}</pre>}
|
| 652 |
</section>
|
| 653 |
)}
|
| 654 |
</div>
|
|
|
|
| 8 |
default_migrate_mode: "copy",
|
| 9 |
scan_recursive: true,
|
| 10 |
experimental_media_enabled: false,
|
| 11 |
+
selected_tags: [],
|
| 12 |
+
max_inference_workers: 2,
|
| 13 |
+
inference_batch_size: 1,
|
| 14 |
+
force_cpu_inference: false,
|
| 15 |
+
tagger_model: "wd_swinv2_v3",
|
| 16 |
+
wd_general_threshold: 0.35,
|
| 17 |
};
|
| 18 |
+
|
| 19 |
+
const TAGGER_MODELS = [
|
| 20 |
+
{
|
| 21 |
+
id: "ml_danbooru",
|
| 22 |
+
label: "ML-Danbooru",
|
| 23 |
+
help: "Original ONNX tagger used by this app. Fast baseline.",
|
| 24 |
+
},
|
| 25 |
+
{
|
| 26 |
+
id: "wd_swinv2_v3",
|
| 27 |
+
label: "WD SwinV2 v3",
|
| 28 |
+
help: "Recommended default — strong general-tag accuracy on anime art.",
|
| 29 |
+
},
|
| 30 |
+
{
|
| 31 |
+
id: "wd_eva02_large",
|
| 32 |
+
label: "WD EVA02 Large",
|
| 33 |
+
help: "Largest WD tagger; slower, often slightly more accurate.",
|
| 34 |
+
},
|
| 35 |
+
];
|
| 36 |
+
|
| 37 |
const ACTIVE_RUN_STORAGE_KEY = "imageClassifierActiveRunId";
|
| 38 |
|
| 39 |
+
function settingsSnapshot(settings, selectedTags) {
|
| 40 |
+
return JSON.stringify({
|
| 41 |
+
...settings,
|
| 42 |
+
selected_tags: selectedTags,
|
| 43 |
+
});
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
function formatTagScore(entry) {
|
| 47 |
+
if (!entry) return "";
|
| 48 |
+
return `${entry.tag} (${Number(entry.score).toFixed(3)})`;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
function App() {
|
| 52 |
const [settings, setSettings] = useState(DEFAULT_SETTINGS);
|
| 53 |
+
const [savedSnapshot, setSavedSnapshot] = useState(
|
| 54 |
+
settingsSnapshot(DEFAULT_SETTINGS, [])
|
| 55 |
+
);
|
| 56 |
const [offlineMode, setOfflineMode] = useState(false);
|
| 57 |
const [runId, setRunId] = useState(null);
|
| 58 |
const [runStatus, setRunStatus] = useState(null);
|
|
|
|
| 71 |
const [scoreDebugByItem, setScoreDebugByItem] = useState({});
|
| 72 |
const [scoreLoadingByItem, setScoreLoadingByItem] = useState({});
|
| 73 |
const [finalTagDrafts, setFinalTagDrafts] = useState({});
|
| 74 |
+
const [scanPreview, setScanPreview] = useState(null);
|
| 75 |
+
const [scanPreviewLoading, setScanPreviewLoading] = useState(false);
|
| 76 |
const [opsLoading, setOpsLoading] = useState({
|
| 77 |
saving: false,
|
| 78 |
startingRun: false,
|
|
|
|
| 82 |
});
|
| 83 |
const finalTagTimersRef = useRef({});
|
| 84 |
const pollTimerRef = useRef(null);
|
| 85 |
+
const tagsHydratedRef = useRef(false);
|
| 86 |
+
const skipNextTagPersistRef = useRef(false);
|
| 87 |
+
|
| 88 |
+
const isDirty = useMemo(
|
| 89 |
+
() => settingsSnapshot(settings, selectedTags) !== savedSnapshot,
|
| 90 |
+
[settings, selectedTags, savedSnapshot]
|
| 91 |
+
);
|
| 92 |
+
|
| 93 |
+
const isWdModel = String(settings.tagger_model || "").startsWith("wd_");
|
| 94 |
+
const runActive = Boolean(
|
| 95 |
+
runStatus && ["pending", "running"].includes(runStatus.status)
|
| 96 |
+
);
|
| 97 |
|
| 98 |
async function refreshItems(currentRunId) {
|
| 99 |
if (!currentRunId) return;
|
|
|
|
| 111 |
}
|
| 112 |
|
| 113 |
useEffect(() => {
|
| 114 |
+
api
|
| 115 |
+
.getSettings()
|
| 116 |
.then((data) => {
|
| 117 |
+
const merged = { ...DEFAULT_SETTINGS, ...data };
|
| 118 |
+
const tags = Array.isArray(data.selected_tags) ? data.selected_tags : [];
|
| 119 |
+
setSettings(merged);
|
| 120 |
+
skipNextTagPersistRef.current = true;
|
| 121 |
+
setSelectedTags(tags);
|
| 122 |
+
tagsHydratedRef.current = true;
|
| 123 |
setMigrateMode(data.default_migrate_mode || "copy");
|
| 124 |
+
setSavedSnapshot(settingsSnapshot(merged, tags));
|
| 125 |
setOfflineMode(api.isOfflineMode());
|
| 126 |
})
|
| 127 |
.catch((err) => setError(err.message));
|
|
|
|
| 129 |
if (storedRunId > 0) {
|
| 130 |
setRunId(storedRunId);
|
| 131 |
}
|
| 132 |
+
api
|
| 133 |
+
.getProviders()
|
| 134 |
.then((info) => setProviderInfo(info))
|
| 135 |
.catch(() => setProviderInfo(null));
|
| 136 |
}, []);
|
| 137 |
|
| 138 |
+
// Persist typed/selected tags (Shuck3r-style preference survival across reloads).
|
| 139 |
+
useEffect(() => {
|
| 140 |
+
if (!tagsHydratedRef.current) return;
|
| 141 |
+
if (skipNextTagPersistRef.current) {
|
| 142 |
+
skipNextTagPersistRef.current = false;
|
| 143 |
+
return;
|
| 144 |
+
}
|
| 145 |
+
if (!Array.isArray(selectedTags)) return;
|
| 146 |
+
const timer = setTimeout(() => {
|
| 147 |
+
const next = { ...settings, selected_tags: selectedTags };
|
| 148 |
+
setSettings(next);
|
| 149 |
+
api
|
| 150 |
+
.saveSettings(next)
|
| 151 |
+
.then((saved) => {
|
| 152 |
+
const merged = { ...DEFAULT_SETTINGS, ...saved };
|
| 153 |
+
setSettings(merged);
|
| 154 |
+
setSavedSnapshot(
|
| 155 |
+
settingsSnapshot(merged, Array.isArray(saved.selected_tags) ? saved.selected_tags : selectedTags)
|
| 156 |
+
);
|
| 157 |
+
})
|
| 158 |
+
.catch(() => {
|
| 159 |
+
/* ignore transient save errors while typing */
|
| 160 |
+
});
|
| 161 |
+
}, 400);
|
| 162 |
+
return () => clearTimeout(timer);
|
| 163 |
+
// Intentionally depend on selectedTags only to avoid save loops from settings edits.
|
| 164 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 165 |
+
}, [selectedTags]);
|
| 166 |
+
|
| 167 |
useEffect(() => {
|
| 168 |
let cancelled = false;
|
| 169 |
const timer = setTimeout(() => {
|
| 170 |
+
api
|
| 171 |
+
.getTags(tagQuery, 50)
|
| 172 |
.then((data) => {
|
| 173 |
if (cancelled) return;
|
| 174 |
setTagOptions(data.items || []);
|
|
|
|
| 247 |
}, [runId]);
|
| 248 |
|
| 249 |
async function handleSaveSettings(e) {
|
| 250 |
+
if (e) e.preventDefault();
|
| 251 |
setLoading(true);
|
| 252 |
setOpsLoading((prev) => ({ ...prev, saving: true }));
|
| 253 |
setError("");
|
| 254 |
try {
|
| 255 |
+
const saved = await api.saveSettings({ ...settings, selected_tags: selectedTags });
|
| 256 |
+
const merged = { ...DEFAULT_SETTINGS, ...saved };
|
| 257 |
+
setSettings(merged);
|
| 258 |
+
const tags = Array.isArray(saved.selected_tags) ? saved.selected_tags : selectedTags;
|
| 259 |
+
if (Array.isArray(saved.selected_tags)) {
|
| 260 |
+
skipNextTagPersistRef.current = true;
|
| 261 |
+
setSelectedTags(saved.selected_tags);
|
| 262 |
+
}
|
| 263 |
+
setSavedSnapshot(settingsSnapshot(merged, tags));
|
| 264 |
setOfflineMode(api.isOfflineMode());
|
| 265 |
+
const providers = await api.getProviders();
|
| 266 |
+
setProviderInfo(providers);
|
| 267 |
} catch (err) {
|
| 268 |
setError(err.message);
|
| 269 |
} finally {
|
|
|
|
| 272 |
}
|
| 273 |
}
|
| 274 |
|
| 275 |
+
async function handlePreviewScan() {
|
| 276 |
+
setScanPreviewLoading(true);
|
| 277 |
+
setError("");
|
| 278 |
+
try {
|
| 279 |
+
if (isDirty) {
|
| 280 |
+
await handleSaveSettings();
|
| 281 |
+
}
|
| 282 |
+
const preview = await api.previewScan();
|
| 283 |
+
setScanPreview(preview);
|
| 284 |
+
} catch (err) {
|
| 285 |
+
setError(err.message);
|
| 286 |
+
setScanPreview(null);
|
| 287 |
+
} finally {
|
| 288 |
+
setScanPreviewLoading(false);
|
| 289 |
+
}
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
async function handleStartRun() {
|
| 293 |
setLoading(true);
|
| 294 |
setOpsLoading((prev) => ({ ...prev, startingRun: true }));
|
| 295 |
setError("");
|
| 296 |
try {
|
| 297 |
+
if (isDirty) {
|
| 298 |
+
await handleSaveSettings();
|
| 299 |
+
}
|
| 300 |
const result = await api.startRun({
|
| 301 |
...settings,
|
| 302 |
selected_folders: selectedTags.length > 0 ? selectedTags : null,
|
|
|
|
| 312 |
failed_images: 0,
|
| 313 |
progress_pct: 0,
|
| 314 |
cancel_requested: false,
|
| 315 |
+
tagger_model: settings.tagger_model,
|
| 316 |
});
|
| 317 |
} catch (err) {
|
| 318 |
setError(err.message);
|
|
|
|
| 437 |
if (!value) return;
|
| 438 |
if (selectedTags.includes(value)) return;
|
| 439 |
|
|
|
|
| 440 |
if (tagOptions.includes(value)) {
|
| 441 |
addSelectedTag(value);
|
| 442 |
return;
|
| 443 |
}
|
| 444 |
|
|
|
|
| 445 |
const result = await api.getTags(value, 200);
|
| 446 |
if ((result.items || []).includes(value)) {
|
| 447 |
addSelectedTag(value);
|
|
|
|
| 469 |
|
| 470 |
return (
|
| 471 |
<div className="container">
|
| 472 |
+
<header className="app-header">
|
| 473 |
+
<h1>Image Classifier</h1>
|
| 474 |
+
<p className="lede">
|
| 475 |
+
Configure paths and tagger model, select destination tags, run inference, then review
|
| 476 |
+
and migrate.
|
| 477 |
+
</p>
|
| 478 |
+
{providerInfo && !offlineMode && (
|
| 479 |
+
<div className="provider-strip">
|
| 480 |
+
<span>
|
| 481 |
+
Device: <strong>{providerInfo.likely_device || "unknown"}</strong>
|
| 482 |
+
</span>
|
| 483 |
+
<span>
|
| 484 |
+
CUDA: <strong>{String(Boolean(providerInfo.cuda_usable))}</strong>
|
| 485 |
+
</span>
|
| 486 |
+
<span>
|
| 487 |
+
Active: <strong>{(providerInfo.active_providers || []).join(", ") || "n/a"}</strong>
|
| 488 |
+
</span>
|
| 489 |
+
<span>
|
| 490 |
+
Tagger: <strong>{providerInfo.tagger_model || settings.tagger_model}</strong>
|
| 491 |
+
</span>
|
| 492 |
+
<span>
|
| 493 |
+
Forced CPU: <strong>{String(Boolean(providerInfo.forced_cpu))}</strong>
|
| 494 |
+
</span>
|
| 495 |
+
</div>
|
| 496 |
+
)}
|
| 497 |
+
</header>
|
| 498 |
+
|
| 499 |
{offlineMode && (
|
| 500 |
<div className="error">
|
| 501 |
Backend is offline. Running in browser-only demo mode with local mock data.
|
| 502 |
</div>
|
| 503 |
)}
|
| 504 |
+
{providerInfo?.provider_error && !offlineMode && (
|
| 505 |
+
<div className="error">{providerInfo.provider_error}</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 506 |
)}
|
|
|
|
| 507 |
{error && <div className="error">{error}</div>}
|
| 508 |
|
| 509 |
+
<section className="panel">
|
| 510 |
+
<h2>Settings</h2>
|
| 511 |
+
<span className="kicker">Paths, model, thresholds, and performance. Save before runs.</span>
|
| 512 |
+
<form onSubmit={handleSaveSettings}>
|
| 513 |
+
<fieldset className="settings-section">
|
| 514 |
+
<legend>Paths & scan</legend>
|
| 515 |
+
<div className="grid">
|
| 516 |
+
<label>
|
| 517 |
+
Root repository
|
| 518 |
+
<input
|
| 519 |
+
value={settings.root_repo}
|
| 520 |
+
onChange={(e) => setSettings({ ...settings, root_repo: e.target.value })}
|
| 521 |
+
placeholder="Folder of unsorted images"
|
| 522 |
+
/>
|
| 523 |
+
</label>
|
| 524 |
+
<label>
|
| 525 |
+
Categories root
|
| 526 |
+
<input
|
| 527 |
+
value={settings.categories_root}
|
| 528 |
+
onChange={(e) => setSettings({ ...settings, categories_root: e.target.value })}
|
| 529 |
+
placeholder="Destination folders root (not tags.csv)"
|
| 530 |
+
/>
|
| 531 |
+
</label>
|
| 532 |
+
<label>
|
| 533 |
+
Default migrate mode
|
| 534 |
+
<select
|
| 535 |
+
value={settings.default_migrate_mode}
|
| 536 |
+
onChange={(e) =>
|
| 537 |
+
setSettings({ ...settings, default_migrate_mode: e.target.value })
|
| 538 |
+
}
|
| 539 |
+
>
|
| 540 |
+
<option value="copy">copy</option>
|
| 541 |
+
<option value="move">move</option>
|
| 542 |
+
</select>
|
| 543 |
+
</label>
|
| 544 |
+
<label className="inline-check">
|
| 545 |
+
<input
|
| 546 |
+
type="checkbox"
|
| 547 |
+
checked={Boolean(settings.scan_recursive)}
|
| 548 |
+
onChange={(e) => setSettings({ ...settings, scan_recursive: e.target.checked })}
|
| 549 |
+
/>
|
| 550 |
+
Scan subfolders recursively
|
| 551 |
+
</label>
|
| 552 |
+
</div>
|
| 553 |
+
<div className="actions">
|
| 554 |
+
<button
|
| 555 |
+
type="button"
|
| 556 |
+
className="secondary"
|
| 557 |
+
disabled={scanPreviewLoading}
|
| 558 |
+
onClick={handlePreviewScan}
|
| 559 |
+
>
|
| 560 |
+
{scanPreviewLoading ? "Scanning…" : "Preview scan"}
|
| 561 |
+
</button>
|
| 562 |
+
</div>
|
| 563 |
+
{scanPreview?.stats && (
|
| 564 |
+
<div className="scan-preview">
|
| 565 |
+
Eligible: {scanPreview.stats.eligible_images} · Total files:{" "}
|
| 566 |
+
{scanPreview.stats.total_files} · Ignored GIF: {scanPreview.stats.ignored_gif} ·
|
| 567 |
+
Unsupported: {scanPreview.stats.ignored_unsupported}
|
| 568 |
+
</div>
|
| 569 |
+
)}
|
| 570 |
+
</fieldset>
|
| 571 |
+
|
| 572 |
+
<fieldset className="settings-section">
|
| 573 |
+
<legend>Tagger model</legend>
|
| 574 |
+
<div className="model-options">
|
| 575 |
+
{TAGGER_MODELS.map((model) => (
|
| 576 |
+
<label key={model.id} className="model-option">
|
| 577 |
+
<input
|
| 578 |
+
type="radio"
|
| 579 |
+
name="tagger_model"
|
| 580 |
+
value={model.id}
|
| 581 |
+
checked={settings.tagger_model === model.id}
|
| 582 |
+
onChange={() => setSettings({ ...settings, tagger_model: model.id })}
|
| 583 |
+
/>
|
| 584 |
+
<span>
|
| 585 |
+
<strong>{model.label}</strong>
|
| 586 |
+
<span>{model.help}</span>
|
| 587 |
+
</span>
|
| 588 |
+
</label>
|
| 589 |
+
))}
|
| 590 |
+
</div>
|
| 591 |
+
</fieldset>
|
| 592 |
+
|
| 593 |
+
<fieldset className="settings-section">
|
| 594 |
+
<legend>Thresholds</legend>
|
| 595 |
+
<div className="grid">
|
| 596 |
+
<label>
|
| 597 |
+
Assignment confidence
|
| 598 |
+
<input
|
| 599 |
+
type="number"
|
| 600 |
+
step="0.01"
|
| 601 |
+
min="0"
|
| 602 |
+
max="1"
|
| 603 |
+
value={settings.confidence_threshold}
|
| 604 |
+
onChange={(e) =>
|
| 605 |
+
setSettings({ ...settings, confidence_threshold: Number(e.target.value) })
|
| 606 |
+
}
|
| 607 |
+
/>
|
| 608 |
+
<span className="help">
|
| 609 |
+
Selected-tag scores below this are suggestions only (no primary assignment).
|
| 610 |
+
</span>
|
| 611 |
+
</label>
|
| 612 |
+
{isWdModel && (
|
| 613 |
+
<label>
|
| 614 |
+
WD general threshold
|
| 615 |
+
<input
|
| 616 |
+
type="number"
|
| 617 |
+
step="0.01"
|
| 618 |
+
min="0"
|
| 619 |
+
max="1"
|
| 620 |
+
value={settings.wd_general_threshold}
|
| 621 |
+
onChange={(e) =>
|
| 622 |
+
setSettings({
|
| 623 |
+
...settings,
|
| 624 |
+
wd_general_threshold: Number(e.target.value),
|
| 625 |
+
})
|
| 626 |
+
}
|
| 627 |
+
/>
|
| 628 |
+
<span className="help">
|
| 629 |
+
WD14 inference cutoff for general tags (default 0.35).
|
| 630 |
+
</span>
|
| 631 |
+
</label>
|
| 632 |
+
)}
|
| 633 |
+
</div>
|
| 634 |
+
</fieldset>
|
| 635 |
+
|
| 636 |
+
<fieldset className="settings-section">
|
| 637 |
+
<legend>Performance</legend>
|
| 638 |
+
<div className="grid">
|
| 639 |
+
<label>
|
| 640 |
+
Max inference workers
|
| 641 |
+
<input
|
| 642 |
+
type="number"
|
| 643 |
+
min="1"
|
| 644 |
+
max="16"
|
| 645 |
+
value={settings.max_inference_workers ?? 2}
|
| 646 |
+
onChange={(e) =>
|
| 647 |
+
setSettings({
|
| 648 |
+
...settings,
|
| 649 |
+
max_inference_workers: Math.max(
|
| 650 |
+
1,
|
| 651 |
+
Math.min(16, Number(e.target.value) || 1)
|
| 652 |
+
),
|
| 653 |
+
})
|
| 654 |
+
}
|
| 655 |
+
/>
|
| 656 |
+
<span className="help">Keep at 2 for GPU ORT sessions (serialized lock).</span>
|
| 657 |
+
</label>
|
| 658 |
+
<label>
|
| 659 |
+
Inference batch size
|
| 660 |
+
<input
|
| 661 |
+
type="number"
|
| 662 |
+
min="1"
|
| 663 |
+
max="64"
|
| 664 |
+
value={settings.inference_batch_size ?? 1}
|
| 665 |
+
onChange={(e) =>
|
| 666 |
+
setSettings({
|
| 667 |
+
...settings,
|
| 668 |
+
inference_batch_size: Math.max(
|
| 669 |
+
1,
|
| 670 |
+
Math.min(64, Number(e.target.value) || 1)
|
| 671 |
+
),
|
| 672 |
+
})
|
| 673 |
+
}
|
| 674 |
+
/>
|
| 675 |
+
</label>
|
| 676 |
+
<label className="inline-check">
|
| 677 |
+
<input
|
| 678 |
+
type="checkbox"
|
| 679 |
+
checked={Boolean(settings.force_cpu_inference)}
|
| 680 |
+
onChange={(e) =>
|
| 681 |
+
setSettings({ ...settings, force_cpu_inference: e.target.checked })
|
| 682 |
+
}
|
| 683 |
+
/>
|
| 684 |
+
Force CPU inference
|
| 685 |
+
</label>
|
| 686 |
+
<label className="inline-check">
|
| 687 |
+
<input
|
| 688 |
+
type="checkbox"
|
| 689 |
+
checked={Boolean(settings.experimental_media_enabled)}
|
| 690 |
+
onChange={(e) =>
|
| 691 |
+
setSettings({ ...settings, experimental_media_enabled: e.target.checked })
|
| 692 |
+
}
|
| 693 |
+
/>
|
| 694 |
+
Experimental: classify GIF/videos via sampled frames
|
| 695 |
+
</label>
|
| 696 |
+
</div>
|
| 697 |
+
</fieldset>
|
| 698 |
+
|
| 699 |
+
<div className="sticky-save">
|
| 700 |
+
<span className={isDirty ? "dirty" : "clean"}>
|
| 701 |
+
{isDirty ? "Unsaved settings changes" : "Settings saved"}
|
| 702 |
+
</span>
|
| 703 |
+
<button type="submit" disabled={loading || opsLoading.saving || !isDirty}>
|
| 704 |
+
{opsLoading.saving ? "Saving…" : "Save settings"}
|
| 705 |
+
</button>
|
| 706 |
+
</div>
|
| 707 |
</form>
|
| 708 |
</section>
|
| 709 |
|
| 710 |
+
<section className="panel">
|
| 711 |
+
<h2>Tag selection</h2>
|
| 712 |
+
<span className="kicker">
|
| 713 |
+
Only these tags compete for folder assignment. Changes auto-save.
|
| 714 |
+
</span>
|
| 715 |
<label>
|
| 716 |
Tag match search (from tags.csv)
|
| 717 |
<input
|
| 718 |
value={tagQuery}
|
| 719 |
onChange={(e) => setTagQuery(e.target.value)}
|
| 720 |
+
list="tag-match-suggestions"
|
| 721 |
+
autoComplete="off"
|
| 722 |
onKeyDown={async (e) => {
|
| 723 |
if (e.key === "Enter") {
|
| 724 |
e.preventDefault();
|
|
|
|
| 730 |
}
|
| 731 |
}
|
| 732 |
}}
|
| 733 |
+
placeholder="Type to search tags (e.g. monster_girl)..."
|
| 734 |
/>
|
| 735 |
+
<datalist id="tag-match-suggestions">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 736 |
{tagOptions.map((tag) => (
|
| 737 |
+
<option key={tag} value={tag} />
|
|
|
|
|
|
|
| 738 |
))}
|
| 739 |
+
</datalist>
|
| 740 |
+
</label>
|
| 741 |
+
{tagQuery.trim() && (
|
| 742 |
+
<div className="tag-suggestions">
|
| 743 |
+
{tagOptions.length === 0 ? (
|
| 744 |
+
<span className="muted">No matching tags</span>
|
| 745 |
+
) : (
|
| 746 |
+
tagOptions.slice(0, 30).map((tag) => (
|
| 747 |
+
<button
|
| 748 |
+
key={tag}
|
| 749 |
+
type="button"
|
| 750 |
+
className="tag-suggestion"
|
| 751 |
+
onClick={() => {
|
| 752 |
+
addSelectedTag(tag);
|
| 753 |
+
setTagQuery("");
|
| 754 |
+
setError("");
|
| 755 |
+
}}
|
| 756 |
+
>
|
| 757 |
+
{tag}
|
| 758 |
+
</button>
|
| 759 |
+
))
|
| 760 |
+
)}
|
| 761 |
+
</div>
|
| 762 |
+
)}
|
| 763 |
+
<div className="actions">
|
| 764 |
<button
|
| 765 |
+
type="button"
|
| 766 |
+
className="secondary"
|
| 767 |
onClick={async () => {
|
| 768 |
setError("");
|
| 769 |
try {
|
|
|
|
| 775 |
>
|
| 776 |
Add typed tag(s)
|
| 777 |
</button>
|
| 778 |
+
<button
|
| 779 |
+
type="button"
|
| 780 |
+
disabled={loading || opsLoading.startingRun || runActive}
|
| 781 |
+
onClick={handleStartRun}
|
| 782 |
+
>
|
| 783 |
+
{opsLoading.startingRun ? "Starting…" : "Start run"}
|
| 784 |
+
</button>
|
| 785 |
</div>
|
| 786 |
<div className="stats">
|
| 787 |
+
{selectedTags.length === 0 ? (
|
| 788 |
+
<span className="muted">No tags selected</span>
|
| 789 |
+
) : (
|
| 790 |
+
selectedTags.map((tag) => (
|
| 791 |
+
<span key={tag} className="chip">
|
| 792 |
+
{tag}
|
| 793 |
+
<button type="button" onClick={() => removeSelectedTag(tag)} aria-label={`Remove ${tag}`}>
|
| 794 |
+
×
|
| 795 |
+
</button>
|
| 796 |
+
</span>
|
| 797 |
+
))
|
| 798 |
+
)}
|
| 799 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 800 |
</section>
|
| 801 |
|
| 802 |
+
{runId && runStatus && (
|
| 803 |
+
<section className="panel">
|
| 804 |
+
<h2>Run dashboard</h2>
|
| 805 |
+
<span className="kicker">
|
| 806 |
+
Run #{runId}
|
| 807 |
+
{runStatus.tagger_model ? ` · ${runStatus.tagger_model}` : ""}
|
| 808 |
+
</span>
|
| 809 |
+
<div className="stats">
|
| 810 |
+
<div className="metric">
|
| 811 |
+
<span className="label">Status</span>
|
| 812 |
+
<span className="value">{runStatus.status}</span>
|
| 813 |
+
</div>
|
| 814 |
+
<div className="metric">
|
| 815 |
+
<span className="label">Total</span>
|
| 816 |
+
<span className="value">{runStatus.total_images}</span>
|
| 817 |
+
</div>
|
| 818 |
+
<div className="metric">
|
| 819 |
+
<span className="label">Processed</span>
|
| 820 |
+
<span className="value">{runStatus.processed_images}</span>
|
| 821 |
+
</div>
|
| 822 |
+
<div className="metric">
|
| 823 |
+
<span className="label">Failed</span>
|
| 824 |
+
<span className="value">{runStatus.failed_images}</span>
|
| 825 |
+
</div>
|
| 826 |
+
<div className="metric">
|
| 827 |
+
<span className="label">Needs review</span>
|
| 828 |
+
<span className="value">{stats.reviewNeeded}</span>
|
| 829 |
+
</div>
|
| 830 |
+
<div className="metric">
|
| 831 |
+
<span className="label">Progress</span>
|
| 832 |
+
<span className="value">{Number(runStatus.progress_pct || 0).toFixed(1)}%</span>
|
| 833 |
+
</div>
|
| 834 |
+
</div>
|
| 835 |
+
<div className="progress-track">
|
| 836 |
+
<div
|
| 837 |
+
className="progress-fill"
|
| 838 |
+
style={{ width: `${Math.min(100, Number(runStatus.progress_pct || 0))}%` }}
|
| 839 |
+
/>
|
| 840 |
+
</div>
|
| 841 |
+
<div className="stats">
|
| 842 |
+
{runStatus.avg_infer_ms_per_image != null && (
|
| 843 |
<span>
|
| 844 |
+
Avg infer: {Number(runStatus.avg_infer_ms_per_image).toFixed(1)} ms/image
|
|
|
|
| 845 |
</span>
|
| 846 |
+
)}
|
| 847 |
+
{runStatus.inference_mode && <span>Mode: {runStatus.inference_mode}</span>}
|
| 848 |
+
{runStatus.cancel_requested && <span>Cancel requested</span>}
|
| 849 |
+
</div>
|
| 850 |
+
{runActive && (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 851 |
<div className="actions">
|
| 852 |
+
<button type="button" className="danger" onClick={cancelActiveRun}>
|
| 853 |
+
Cancel run
|
| 854 |
+
</button>
|
| 855 |
</div>
|
| 856 |
)}
|
| 857 |
+
{runStatus.last_error && <div className="error">{runStatus.last_error}</div>}
|
| 858 |
+
</section>
|
| 859 |
+
)}
|
| 860 |
+
|
| 861 |
+
{runId && (
|
| 862 |
+
<section className="panel">
|
| 863 |
+
<h2>Review</h2>
|
| 864 |
+
<span className="kicker">
|
| 865 |
+
Primary is assigned only when a selected tag clears confidence and noise floor.
|
| 866 |
+
Global tops help spot mis-assignments.
|
| 867 |
+
</span>
|
| 868 |
<div className="stats">
|
| 869 |
+
<span>Listed: {stats.total}</span>
|
| 870 |
+
<span>Needs review: {stats.reviewNeeded}</span>
|
| 871 |
<span>Approved: {stats.approved}</span>
|
| 872 |
<span>Migrated: {stats.migrated}</span>
|
| 873 |
</div>
|
| 874 |
<div className="actions">
|
| 875 |
+
<button
|
| 876 |
+
type="button"
|
| 877 |
+
disabled={opsLoading.applyingBatch}
|
| 878 |
+
onClick={() => applyBatch("approved")}
|
| 879 |
+
>
|
| 880 |
+
Approve selected
|
| 881 |
</button>
|
| 882 |
+
<button
|
| 883 |
+
type="button"
|
| 884 |
+
className="secondary"
|
| 885 |
+
disabled={opsLoading.applyingBatch}
|
| 886 |
+
onClick={() => applyBatch("rejected")}
|
| 887 |
+
>
|
| 888 |
+
Reject selected
|
| 889 |
</button>
|
| 890 |
<select value={migrateMode} onChange={(e) => setMigrateMode(e.target.value)}>
|
| 891 |
<option value="copy">copy</option>
|
| 892 |
<option value="move">move</option>
|
| 893 |
</select>
|
| 894 |
+
<button type="button" disabled={opsLoading.migrating} onClick={migrateApproved}>
|
| 895 |
+
{opsLoading.migrating ? "Migrating…" : "Migrate approved"}
|
| 896 |
</button>
|
| 897 |
</div>
|
| 898 |
|
|
|
|
| 901 |
<tr>
|
| 902 |
<th>Select</th>
|
| 903 |
<th>Image</th>
|
| 904 |
+
<th>Primary</th>
|
| 905 |
+
<th>Secondary</th>
|
| 906 |
+
<th>Global top</th>
|
| 907 |
<th>Status</th>
|
| 908 |
+
<th>Final tag</th>
|
| 909 |
+
<th>Debug</th>
|
| 910 |
+
<th>Actions</th>
|
| 911 |
</tr>
|
| 912 |
</thead>
|
| 913 |
<tbody>
|
|
|
|
| 943 |
/>
|
| 944 |
) : null}
|
| 945 |
<div className="image-path">{item.relative_path || item.file_path || "-"}</div>
|
| 946 |
+
{item.review_reason && (
|
| 947 |
+
<div className="help">{item.review_reason}</div>
|
| 948 |
+
)}
|
| 949 |
+
</div>
|
| 950 |
+
</td>
|
| 951 |
+
<td>
|
| 952 |
+
{item.primary_tag ? (
|
| 953 |
+
<>
|
| 954 |
+
{item.primary_tag}
|
| 955 |
+
<div className="muted">
|
| 956 |
+
{item.primary_score != null
|
| 957 |
+
? Number(item.primary_score).toFixed(3)
|
| 958 |
+
: ""}
|
| 959 |
+
</div>
|
| 960 |
+
</>
|
| 961 |
+
) : (
|
| 962 |
+
<span className="primary-empty">needs review</span>
|
| 963 |
+
)}
|
| 964 |
+
</td>
|
| 965 |
+
<td>
|
| 966 |
+
<div className="tag-list">
|
| 967 |
+
{(item.secondary_suggestions || []).length === 0
|
| 968 |
+
? "-"
|
| 969 |
+
: (item.secondary_suggestions || []).map((s) => (
|
| 970 |
+
<span key={`${item.id}-${s.tag}`}>{formatTagScore(s)}</span>
|
| 971 |
+
))}
|
| 972 |
</div>
|
| 973 |
</td>
|
|
|
|
|
|
|
| 974 |
<td>
|
| 975 |
+
<div className="tag-list">
|
| 976 |
+
{(item.global_top_tags || []).length === 0
|
| 977 |
+
? "-"
|
| 978 |
+
: (item.global_top_tags || []).slice(0, 5).map((s) => (
|
| 979 |
+
<span key={`${item.id}-g-${s.tag}`}>{formatTagScore(s)}</span>
|
| 980 |
+
))}
|
| 981 |
+
</div>
|
| 982 |
</td>
|
| 983 |
<td>{item.status}</td>
|
| 984 |
<td>
|
|
|
|
| 988 |
/>
|
| 989 |
</td>
|
| 990 |
<td>
|
| 991 |
+
<button type="button" className="secondary" onClick={() => toggleScoreDebug(item.id)}>
|
| 992 |
{expandedScoreRows[item.id] ? "Hide JSON" : "Show JSON"}
|
| 993 |
</button>
|
| 994 |
{expandedScoreRows[item.id] && (
|
|
|
|
| 1000 |
)}
|
| 1001 |
</td>
|
| 1002 |
<td>
|
| 1003 |
+
<div className="actions">
|
| 1004 |
+
<button
|
| 1005 |
+
type="button"
|
| 1006 |
+
disabled={opsLoading.updatingStatus}
|
| 1007 |
+
onClick={() => updateStatus(item.id, "approved")}
|
| 1008 |
+
>
|
| 1009 |
+
Approve
|
| 1010 |
+
</button>
|
| 1011 |
+
<button
|
| 1012 |
+
type="button"
|
| 1013 |
+
className="secondary"
|
| 1014 |
+
disabled={opsLoading.updatingStatus}
|
| 1015 |
+
onClick={() => updateStatus(item.id, "rejected")}
|
| 1016 |
+
>
|
| 1017 |
+
Reject
|
| 1018 |
+
</button>
|
| 1019 |
+
<button
|
| 1020 |
+
type="button"
|
| 1021 |
+
className="secondary"
|
| 1022 |
+
disabled={opsLoading.updatingStatus}
|
| 1023 |
+
onClick={() => updateStatus(item.id, "reviewed")}
|
| 1024 |
+
>
|
| 1025 |
+
Reviewed
|
| 1026 |
+
</button>
|
| 1027 |
+
</div>
|
| 1028 |
</td>
|
| 1029 |
</tr>
|
| 1030 |
))}
|
| 1031 |
</tbody>
|
| 1032 |
</table>
|
| 1033 |
+
{runMeta && <pre className="debug-json">{JSON.stringify(runMeta.counts, null, 2)}</pre>}
|
| 1034 |
</section>
|
| 1035 |
)}
|
| 1036 |
</div>
|
frontend/src/api.js
CHANGED
|
@@ -8,6 +8,12 @@ const defaultSettings = {
|
|
| 8 |
default_migrate_mode: "copy",
|
| 9 |
scan_recursive: true,
|
| 10 |
experimental_media_enabled: false,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
};
|
| 12 |
const mockTags = [
|
| 13 |
"1girl",
|
|
@@ -136,6 +142,9 @@ function makeMockItems(runId, selectedFolders, threshold) {
|
|
| 136 |
.filter((x) => x !== tag)
|
| 137 |
.slice(0, 3)
|
| 138 |
.map((x, i) => ({ tag: x, score: Math.max(0.2, score - 0.1 - i * 0.05) })),
|
|
|
|
|
|
|
|
|
|
| 139 |
suggested_destination: `${mockState.settings.categories_root || "/mock/categories"}/${tag}`,
|
| 140 |
final_tag: tag,
|
| 141 |
final_destination: `${mockState.settings.categories_root || "/mock/categories"}/${tag}`,
|
|
@@ -173,6 +182,7 @@ function computeMockStatus(run) {
|
|
| 173 |
batch_size: 1,
|
| 174 |
avg_infer_ms_per_image: null,
|
| 175 |
queue_seed: null,
|
|
|
|
| 176 |
};
|
| 177 |
}
|
| 178 |
|
|
@@ -190,9 +200,29 @@ function mockRequest(path, options = {}) {
|
|
| 190 |
cpu_available: true,
|
| 191 |
forced_cpu: false,
|
| 192 |
likely_device: "cpu",
|
|
|
|
|
|
|
| 193 |
mock_mode: true,
|
| 194 |
});
|
| 195 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
if (path.startsWith("/tags") && method === "GET") {
|
| 197 |
const queryString = path.includes("?") ? path.split("?")[1] : "";
|
| 198 |
const params = new URLSearchParams(queryString);
|
|
@@ -223,6 +253,7 @@ function mockRequest(path, options = {}) {
|
|
| 223 |
root_repo: mockState.settings.root_repo,
|
| 224 |
categories_root: mockState.settings.categories_root,
|
| 225 |
confidence_threshold: threshold,
|
|
|
|
| 226 |
status: "pending",
|
| 227 |
total_images: items.length,
|
| 228 |
processed_images: 0,
|
|
@@ -374,12 +405,12 @@ export const api = {
|
|
| 374 |
},
|
| 375 |
getSettings: () => request("/settings"),
|
| 376 |
getProviders: () => request("/providers"),
|
|
|
|
| 377 |
saveSettings: (payload) =>
|
| 378 |
request("/settings", {
|
| 379 |
method: "PUT",
|
| 380 |
body: JSON.stringify(payload),
|
| 381 |
-
}),
|
| 382 |
-
startRun: (payload) =>
|
| 383 |
request("/runs/start", {
|
| 384 |
method: "POST",
|
| 385 |
body: JSON.stringify(payload),
|
|
|
|
| 8 |
default_migrate_mode: "copy",
|
| 9 |
scan_recursive: true,
|
| 10 |
experimental_media_enabled: false,
|
| 11 |
+
selected_tags: [],
|
| 12 |
+
max_inference_workers: 2,
|
| 13 |
+
inference_batch_size: 1,
|
| 14 |
+
force_cpu_inference: false,
|
| 15 |
+
tagger_model: "wd_swinv2_v3",
|
| 16 |
+
wd_general_threshold: 0.35,
|
| 17 |
};
|
| 18 |
const mockTags = [
|
| 19 |
"1girl",
|
|
|
|
| 142 |
.filter((x) => x !== tag)
|
| 143 |
.slice(0, 3)
|
| 144 |
.map((x, i) => ({ tag: x, score: Math.max(0.2, score - 0.1 - i * 0.05) })),
|
| 145 |
+
global_top_tags: [tag, ...pool.filter((x) => x !== tag)]
|
| 146 |
+
.slice(0, 5)
|
| 147 |
+
.map((x, i) => ({ tag: x, score: Math.max(0.15, score - i * 0.07) })),
|
| 148 |
suggested_destination: `${mockState.settings.categories_root || "/mock/categories"}/${tag}`,
|
| 149 |
final_tag: tag,
|
| 150 |
final_destination: `${mockState.settings.categories_root || "/mock/categories"}/${tag}`,
|
|
|
|
| 182 |
batch_size: 1,
|
| 183 |
avg_infer_ms_per_image: null,
|
| 184 |
queue_seed: null,
|
| 185 |
+
tagger_model: run.tagger_model || mockState.settings.tagger_model || "wd_swinv2_v3",
|
| 186 |
};
|
| 187 |
}
|
| 188 |
|
|
|
|
| 200 |
cpu_available: true,
|
| 201 |
forced_cpu: false,
|
| 202 |
likely_device: "cpu",
|
| 203 |
+
cuda_usable: false,
|
| 204 |
+
tagger_model: mockState.settings.tagger_model || "wd_swinv2_v3",
|
| 205 |
mock_mode: true,
|
| 206 |
});
|
| 207 |
}
|
| 208 |
+
if (path === "/scan/preview" && method === "GET") {
|
| 209 |
+
return Promise.resolve({
|
| 210 |
+
root_repo: mockState.settings.root_repo || "/mock/images",
|
| 211 |
+
recursive: Boolean(mockState.settings.scan_recursive),
|
| 212 |
+
experimental_media_enabled: Boolean(mockState.settings.experimental_media_enabled),
|
| 213 |
+
excluded_dirs: mockState.settings.categories_root
|
| 214 |
+
? [mockState.settings.categories_root]
|
| 215 |
+
: [],
|
| 216 |
+
stats: {
|
| 217 |
+
total_files: 12,
|
| 218 |
+
eligible_images: 8,
|
| 219 |
+
ignored_unsupported: 3,
|
| 220 |
+
ignored_gif: 1,
|
| 221 |
+
failed_to_read: 0,
|
| 222 |
+
},
|
| 223 |
+
sample_paths: ["/mock/images/sample_1.jpg"],
|
| 224 |
+
});
|
| 225 |
+
}
|
| 226 |
if (path.startsWith("/tags") && method === "GET") {
|
| 227 |
const queryString = path.includes("?") ? path.split("?")[1] : "";
|
| 228 |
const params = new URLSearchParams(queryString);
|
|
|
|
| 253 |
root_repo: mockState.settings.root_repo,
|
| 254 |
categories_root: mockState.settings.categories_root,
|
| 255 |
confidence_threshold: threshold,
|
| 256 |
+
tagger_model: mockState.settings.tagger_model || "wd_swinv2_v3",
|
| 257 |
status: "pending",
|
| 258 |
total_images: items.length,
|
| 259 |
processed_images: 0,
|
|
|
|
| 405 |
},
|
| 406 |
getSettings: () => request("/settings"),
|
| 407 |
getProviders: () => request("/providers"),
|
| 408 |
+
previewScan: () => request("/scan/preview"),
|
| 409 |
saveSettings: (payload) =>
|
| 410 |
request("/settings", {
|
| 411 |
method: "PUT",
|
| 412 |
body: JSON.stringify(payload),
|
| 413 |
+
}), startRun: (payload) =>
|
|
|
|
| 414 |
request("/runs/start", {
|
| 415 |
method: "POST",
|
| 416 |
body: JSON.stringify(payload),
|
frontend/src/styles.css
CHANGED
|
@@ -1,22 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
body {
|
| 2 |
margin: 0;
|
| 3 |
-
font-family:
|
| 4 |
-
background:
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
}
|
| 7 |
|
| 8 |
.container {
|
| 9 |
-
max-width:
|
| 10 |
margin: 0 auto;
|
| 11 |
-
padding:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
}
|
| 13 |
|
| 14 |
-
.
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
margin-bottom: 16px;
|
| 19 |
-
box-shadow:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
}
|
| 21 |
|
| 22 |
.grid {
|
|
@@ -25,38 +127,235 @@ body {
|
|
| 25 |
gap: 12px;
|
| 26 |
}
|
| 27 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
label {
|
| 29 |
display: flex;
|
| 30 |
flex-direction: column;
|
| 31 |
gap: 6px;
|
| 32 |
font-size: 14px;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
}
|
| 34 |
|
| 35 |
input,
|
| 36 |
select,
|
| 37 |
-
button
|
| 38 |
-
|
| 39 |
-
|
|
|
|
| 40 |
border-radius: 8px;
|
|
|
|
|
|
|
|
|
|
| 41 |
}
|
| 42 |
|
| 43 |
button {
|
| 44 |
cursor: pointer;
|
| 45 |
-
background:
|
| 46 |
border: none;
|
| 47 |
color: #fff;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
}
|
| 49 |
|
| 50 |
.stats {
|
| 51 |
display: flex;
|
| 52 |
-
|
|
|
|
| 53 |
margin: 10px 0;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
}
|
| 55 |
|
| 56 |
.actions {
|
| 57 |
display: flex;
|
|
|
|
| 58 |
gap: 10px;
|
| 59 |
margin: 10px 0;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
}
|
| 61 |
|
| 62 |
table {
|
|
@@ -67,22 +366,36 @@ table {
|
|
| 67 |
|
| 68 |
th,
|
| 69 |
td {
|
| 70 |
-
border: 1px solid
|
| 71 |
-
padding:
|
| 72 |
text-align: left;
|
| 73 |
vertical-align: top;
|
| 74 |
}
|
| 75 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
.needs-review {
|
| 77 |
-
background:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
}
|
| 79 |
|
| 80 |
.error {
|
| 81 |
-
background:
|
| 82 |
border: 1px solid #f5b7b7;
|
| 83 |
border-radius: 8px;
|
| 84 |
padding: 10px;
|
| 85 |
margin-bottom: 12px;
|
|
|
|
| 86 |
}
|
| 87 |
|
| 88 |
.image-cell {
|
|
@@ -96,7 +409,7 @@ td {
|
|
| 96 |
width: 140px;
|
| 97 |
height: 140px;
|
| 98 |
object-fit: contain;
|
| 99 |
-
border: 1px solid
|
| 100 |
border-radius: 8px;
|
| 101 |
background: #f8faff;
|
| 102 |
}
|
|
@@ -107,6 +420,13 @@ td {
|
|
| 107 |
overflow-wrap: anywhere;
|
| 108 |
}
|
| 109 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
.debug-json {
|
| 111 |
margin-top: 6px;
|
| 112 |
max-width: 360px;
|
|
@@ -114,8 +434,19 @@ td {
|
|
| 114 |
overflow: auto;
|
| 115 |
font-size: 11px;
|
| 116 |
line-height: 1.35;
|
| 117 |
-
background: #
|
| 118 |
color: #e2e8f0;
|
| 119 |
border-radius: 8px;
|
| 120 |
padding: 8px;
|
| 121 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
--bg: #eef1f6;
|
| 3 |
+
--bg-accent: #e4ebf5;
|
| 4 |
+
--surface: #fbfcfe;
|
| 5 |
+
--surface-2: #f3f6fb;
|
| 6 |
+
--ink: #1a2332;
|
| 7 |
+
--muted: #5b667a;
|
| 8 |
+
--line: #d5dde9;
|
| 9 |
+
--accent: #245bdb;
|
| 10 |
+
--accent-soft: #dce7ff;
|
| 11 |
+
--warn: #8a5a00;
|
| 12 |
+
--warn-bg: #fff6e4;
|
| 13 |
+
--danger: #9b2c2c;
|
| 14 |
+
--danger-bg: #ffe8e8;
|
| 15 |
+
--ok: #166534;
|
| 16 |
+
--radius: 10px;
|
| 17 |
+
--shadow: 0 1px 2px rgba(26, 35, 50, 0.06);
|
| 18 |
+
--font-ui: "Figtree", "Segoe UI", sans-serif;
|
| 19 |
+
--font-display: "Fraunces", Georgia, serif;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
* {
|
| 23 |
+
box-sizing: border-box;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
body {
|
| 27 |
margin: 0;
|
| 28 |
+
font-family: var(--font-ui);
|
| 29 |
+
background:
|
| 30 |
+
radial-gradient(1200px 500px at 10% -10%, #d9e6ff 0%, transparent 55%),
|
| 31 |
+
radial-gradient(900px 400px at 100% 0%, #e8f0ea 0%, transparent 50%),
|
| 32 |
+
var(--bg);
|
| 33 |
+
color: var(--ink);
|
| 34 |
+
min-height: 100vh;
|
| 35 |
}
|
| 36 |
|
| 37 |
.container {
|
| 38 |
+
max-width: 1180px;
|
| 39 |
margin: 0 auto;
|
| 40 |
+
padding: 28px 20px 96px;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
.app-header {
|
| 44 |
+
margin-bottom: 22px;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
.app-header h1 {
|
| 48 |
+
margin: 0 0 6px;
|
| 49 |
+
font-family: var(--font-display);
|
| 50 |
+
font-size: clamp(1.8rem, 3vw, 2.4rem);
|
| 51 |
+
font-weight: 700;
|
| 52 |
+
letter-spacing: -0.02em;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
.lede {
|
| 56 |
+
margin: 0 0 14px;
|
| 57 |
+
color: var(--muted);
|
| 58 |
+
max-width: 62ch;
|
| 59 |
+
line-height: 1.45;
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
.provider-strip {
|
| 63 |
+
display: flex;
|
| 64 |
+
flex-wrap: wrap;
|
| 65 |
+
gap: 8px 14px;
|
| 66 |
+
padding: 10px 12px;
|
| 67 |
+
background: var(--surface);
|
| 68 |
+
border: 1px solid var(--line);
|
| 69 |
+
border-radius: var(--radius);
|
| 70 |
+
box-shadow: var(--shadow);
|
| 71 |
+
font-size: 13px;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
.provider-strip span {
|
| 75 |
+
color: var(--muted);
|
| 76 |
}
|
| 77 |
|
| 78 |
+
.provider-strip strong {
|
| 79 |
+
color: var(--ink);
|
| 80 |
+
font-weight: 600;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
.panel {
|
| 84 |
+
background: var(--surface);
|
| 85 |
+
border: 1px solid var(--line);
|
| 86 |
+
border-radius: var(--radius);
|
| 87 |
+
padding: 18px 18px 14px;
|
| 88 |
margin-bottom: 16px;
|
| 89 |
+
box-shadow: var(--shadow);
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
.panel h2 {
|
| 93 |
+
margin: 0 0 4px;
|
| 94 |
+
font-family: var(--font-display);
|
| 95 |
+
font-size: 1.35rem;
|
| 96 |
+
font-weight: 600;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
.kicker {
|
| 100 |
+
display: block;
|
| 101 |
+
margin-bottom: 12px;
|
| 102 |
+
color: var(--muted);
|
| 103 |
+
font-size: 13px;
|
| 104 |
+
line-height: 1.4;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
.settings-section {
|
| 108 |
+
border: 1px solid var(--line);
|
| 109 |
+
border-radius: 8px;
|
| 110 |
+
padding: 12px 14px 14px;
|
| 111 |
+
margin: 0 0 12px;
|
| 112 |
+
background: var(--surface-2);
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
.settings-section > legend {
|
| 116 |
+
padding: 0 6px;
|
| 117 |
+
font-size: 12px;
|
| 118 |
+
font-weight: 700;
|
| 119 |
+
letter-spacing: 0.04em;
|
| 120 |
+
text-transform: uppercase;
|
| 121 |
+
color: var(--muted);
|
| 122 |
}
|
| 123 |
|
| 124 |
.grid {
|
|
|
|
| 127 |
gap: 12px;
|
| 128 |
}
|
| 129 |
|
| 130 |
+
.grid-1 {
|
| 131 |
+
grid-template-columns: 1fr;
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
label {
|
| 135 |
display: flex;
|
| 136 |
flex-direction: column;
|
| 137 |
gap: 6px;
|
| 138 |
font-size: 14px;
|
| 139 |
+
font-weight: 500;
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
.inline-check {
|
| 143 |
+
flex-direction: row;
|
| 144 |
+
align-items: center;
|
| 145 |
+
gap: 0.5rem;
|
| 146 |
+
font-weight: 500;
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
.help {
|
| 150 |
+
margin: 4px 0 0;
|
| 151 |
+
color: var(--muted);
|
| 152 |
+
font-size: 12px;
|
| 153 |
+
font-weight: 400;
|
| 154 |
+
line-height: 1.35;
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
.model-options {
|
| 158 |
+
display: grid;
|
| 159 |
+
gap: 8px;
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
.model-option {
|
| 163 |
+
display: flex;
|
| 164 |
+
gap: 10px;
|
| 165 |
+
align-items: flex-start;
|
| 166 |
+
padding: 10px 12px;
|
| 167 |
+
border: 1px solid var(--line);
|
| 168 |
+
border-radius: 8px;
|
| 169 |
+
background: var(--surface);
|
| 170 |
+
cursor: pointer;
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
.model-option:has(input:checked) {
|
| 174 |
+
border-color: #9bb6f0;
|
| 175 |
+
background: var(--accent-soft);
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
.model-option input {
|
| 179 |
+
margin-top: 3px;
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
.model-option strong {
|
| 183 |
+
display: block;
|
| 184 |
+
font-size: 14px;
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
.model-option span {
|
| 188 |
+
display: block;
|
| 189 |
+
color: var(--muted);
|
| 190 |
+
font-size: 12px;
|
| 191 |
+
font-weight: 400;
|
| 192 |
+
margin-top: 2px;
|
| 193 |
}
|
| 194 |
|
| 195 |
input,
|
| 196 |
select,
|
| 197 |
+
button,
|
| 198 |
+
textarea {
|
| 199 |
+
padding: 8px 10px;
|
| 200 |
+
border: 1px solid var(--line);
|
| 201 |
border-radius: 8px;
|
| 202 |
+
font: inherit;
|
| 203 |
+
color: var(--ink);
|
| 204 |
+
background: #fff;
|
| 205 |
}
|
| 206 |
|
| 207 |
button {
|
| 208 |
cursor: pointer;
|
| 209 |
+
background: var(--accent);
|
| 210 |
border: none;
|
| 211 |
color: #fff;
|
| 212 |
+
font-weight: 600;
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
button:disabled {
|
| 216 |
+
opacity: 0.55;
|
| 217 |
+
cursor: not-allowed;
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
button.secondary {
|
| 221 |
+
background: #e8edf7;
|
| 222 |
+
color: var(--ink);
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
button.danger {
|
| 226 |
+
background: #b42318;
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
.sticky-save {
|
| 230 |
+
position: sticky;
|
| 231 |
+
bottom: 12px;
|
| 232 |
+
z-index: 20;
|
| 233 |
+
display: flex;
|
| 234 |
+
align-items: center;
|
| 235 |
+
justify-content: space-between;
|
| 236 |
+
gap: 12px;
|
| 237 |
+
margin-top: 14px;
|
| 238 |
+
padding: 10px 12px;
|
| 239 |
+
border: 1px solid #b8c9f0;
|
| 240 |
+
border-radius: 10px;
|
| 241 |
+
background: rgba(251, 252, 254, 0.96);
|
| 242 |
+
backdrop-filter: blur(6px);
|
| 243 |
+
box-shadow: 0 8px 24px rgba(26, 35, 50, 0.12);
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
.sticky-save .dirty {
|
| 247 |
+
color: var(--warn);
|
| 248 |
+
font-size: 13px;
|
| 249 |
+
font-weight: 600;
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
.sticky-save .clean {
|
| 253 |
+
color: var(--muted);
|
| 254 |
+
font-size: 13px;
|
| 255 |
}
|
| 256 |
|
| 257 |
.stats {
|
| 258 |
display: flex;
|
| 259 |
+
flex-wrap: wrap;
|
| 260 |
+
gap: 10px 16px;
|
| 261 |
margin: 10px 0;
|
| 262 |
+
font-size: 13px;
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
.metric {
|
| 266 |
+
min-width: 88px;
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
.metric .label {
|
| 270 |
+
display: block;
|
| 271 |
+
color: var(--muted);
|
| 272 |
+
font-size: 11px;
|
| 273 |
+
text-transform: uppercase;
|
| 274 |
+
letter-spacing: 0.04em;
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
.metric .value {
|
| 278 |
+
font-weight: 700;
|
| 279 |
+
font-size: 1.05rem;
|
| 280 |
}
|
| 281 |
|
| 282 |
.actions {
|
| 283 |
display: flex;
|
| 284 |
+
flex-wrap: wrap;
|
| 285 |
gap: 10px;
|
| 286 |
margin: 10px 0;
|
| 287 |
+
align-items: center;
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
.progress-track {
|
| 291 |
+
width: 100%;
|
| 292 |
+
height: 5px;
|
| 293 |
+
background: #dde4f0;
|
| 294 |
+
border-radius: 999px;
|
| 295 |
+
overflow: hidden;
|
| 296 |
+
margin: 8px 0 12px;
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
.progress-fill {
|
| 300 |
+
height: 100%;
|
| 301 |
+
background: linear-gradient(90deg, #2f6cf0, #4aa3ff);
|
| 302 |
+
border-radius: 999px;
|
| 303 |
+
transition: width 0.25s ease;
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
.tag-suggestions {
|
| 307 |
+
display: flex;
|
| 308 |
+
flex-wrap: wrap;
|
| 309 |
+
gap: 6px;
|
| 310 |
+
margin: 8px 0 4px;
|
| 311 |
+
max-height: 160px;
|
| 312 |
+
overflow-y: auto;
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
.tag-suggestion {
|
| 316 |
+
background: var(--accent-soft);
|
| 317 |
+
color: #1e3a8a;
|
| 318 |
+
border: 1px solid #c7d2fe;
|
| 319 |
+
font-size: 13px;
|
| 320 |
+
padding: 4px 8px;
|
| 321 |
+
font-weight: 500;
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
.tag-suggestion:hover {
|
| 325 |
+
background: #dbeafe;
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
.chip {
|
| 329 |
+
display: inline-flex;
|
| 330 |
+
align-items: center;
|
| 331 |
+
gap: 6px;
|
| 332 |
+
padding: 4px 8px;
|
| 333 |
+
background: #eef2ff;
|
| 334 |
+
border: 1px solid #c7d2fe;
|
| 335 |
+
border-radius: 999px;
|
| 336 |
+
font-size: 13px;
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
.chip button {
|
| 340 |
+
padding: 0 6px;
|
| 341 |
+
border-radius: 999px;
|
| 342 |
+
background: transparent;
|
| 343 |
+
color: #334155;
|
| 344 |
+
font-weight: 700;
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
.muted {
|
| 348 |
+
color: var(--muted);
|
| 349 |
+
font-size: 13px;
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
.scan-preview {
|
| 353 |
+
margin-top: 8px;
|
| 354 |
+
padding: 10px 12px;
|
| 355 |
+
border-radius: 8px;
|
| 356 |
+
background: #eef7f0;
|
| 357 |
+
border: 1px solid #c9e2d1;
|
| 358 |
+
font-size: 13px;
|
| 359 |
}
|
| 360 |
|
| 361 |
table {
|
|
|
|
| 366 |
|
| 367 |
th,
|
| 368 |
td {
|
| 369 |
+
border: 1px solid var(--line);
|
| 370 |
+
padding: 8px;
|
| 371 |
text-align: left;
|
| 372 |
vertical-align: top;
|
| 373 |
}
|
| 374 |
|
| 375 |
+
th {
|
| 376 |
+
background: var(--surface-2);
|
| 377 |
+
font-size: 12px;
|
| 378 |
+
text-transform: uppercase;
|
| 379 |
+
letter-spacing: 0.03em;
|
| 380 |
+
color: var(--muted);
|
| 381 |
+
}
|
| 382 |
+
|
| 383 |
.needs-review {
|
| 384 |
+
background: var(--warn-bg);
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
.primary-empty {
|
| 388 |
+
color: var(--warn);
|
| 389 |
+
font-weight: 600;
|
| 390 |
}
|
| 391 |
|
| 392 |
.error {
|
| 393 |
+
background: var(--danger-bg);
|
| 394 |
border: 1px solid #f5b7b7;
|
| 395 |
border-radius: 8px;
|
| 396 |
padding: 10px;
|
| 397 |
margin-bottom: 12px;
|
| 398 |
+
color: var(--danger);
|
| 399 |
}
|
| 400 |
|
| 401 |
.image-cell {
|
|
|
|
| 409 |
width: 140px;
|
| 410 |
height: 140px;
|
| 411 |
object-fit: contain;
|
| 412 |
+
border: 1px solid var(--line);
|
| 413 |
border-radius: 8px;
|
| 414 |
background: #f8faff;
|
| 415 |
}
|
|
|
|
| 420 |
overflow-wrap: anywhere;
|
| 421 |
}
|
| 422 |
|
| 423 |
+
.tag-list {
|
| 424 |
+
display: flex;
|
| 425 |
+
flex-direction: column;
|
| 426 |
+
gap: 2px;
|
| 427 |
+
font-size: 12px;
|
| 428 |
+
}
|
| 429 |
+
|
| 430 |
.debug-json {
|
| 431 |
margin-top: 6px;
|
| 432 |
max-width: 360px;
|
|
|
|
| 434 |
overflow: auto;
|
| 435 |
font-size: 11px;
|
| 436 |
line-height: 1.35;
|
| 437 |
+
background: #152033;
|
| 438 |
color: #e2e8f0;
|
| 439 |
border-radius: 8px;
|
| 440 |
padding: 8px;
|
| 441 |
}
|
| 442 |
+
|
| 443 |
+
@media (max-width: 760px) {
|
| 444 |
+
.grid {
|
| 445 |
+
grid-template-columns: 1fr;
|
| 446 |
+
}
|
| 447 |
+
|
| 448 |
+
.sticky-save {
|
| 449 |
+
flex-direction: column;
|
| 450 |
+
align-items: stretch;
|
| 451 |
+
}
|
| 452 |
+
}
|