Dinamush Cursor commited on
Commit
f68a2b5
·
1 Parent(s): d4bed75

Add multi-model tagging and SHUCK3R-style classifier UX.

Browse files

Persist 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 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 attempts to use ONNX Runtime CUDA provider when available. If CUDA/cuDNN dependencies are missing, runtime falls back to CPU and logs provider errors.
109
 
110
- ### Verify GPU visibility
 
 
 
 
 
 
 
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"` means CUDA provider is visible and CPU is not being force-disabled.
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", "batch").strip().lower()
85
  return "single" if raw == "single" else "batch"
86
 
87
 
88
- def _get_inference_batch_size() -> int:
89
- raw = os.getenv("INFERENCE_BATCH_SIZE", "8").strip()
 
 
 
90
  try:
91
  value = int(raw)
92
  except ValueError:
93
- value = 8
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(image_path, experimental_media_enabled)
 
 
 
 
 
139
  else:
140
- # Keep base path unchanged so existing mocks/tests and behavior remain stable.
141
- scores = extract_scores(image_path)
142
- primary_tag, primary_score, secondary = choose_best_tags(scores, matched_tags)
143
- needs_review = False
144
- reason = None
145
- if primary_tag is None:
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], matched_tags, confidence_threshold, experimental_media_enabled
 
 
 
 
 
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(p, matched_tags, confidence_threshold, experimental_media_enabled)
 
 
 
 
 
 
 
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(image_paths)
 
 
 
 
212
  if len(scores_by_image) != len(image_paths):
213
  raise RuntimeError("Batch inference result count mismatch")
214
- rows: list[_ImageInferenceResult] = []
215
- for image_path, scores in zip(image_paths, scores_by_image):
216
- primary_tag, primary_score, secondary = choose_best_tags(scores, matched_tags)
217
- needs_review = False
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], matched_tags, confidence_threshold, experimental_media_enabled
 
 
 
 
 
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
- full_scores=(
292
- from_json(row.get("full_scores_json") or "{}", default={})
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
- force_cpu = os.getenv("FORCE_CPU_INFERENCE", "").strip().lower() in {"1", "true", "yes", "on"}
610
- try:
611
- import onnxruntime as ort
612
-
613
- providers = list(ort.get_available_providers())
614
- cuda_available = "CUDAExecutionProvider" in providers
615
- return {
616
- "available_providers": providers,
617
- "cuda_available": cuda_available,
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, payload.selected_folders)
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(status_code=400, detail="No folder names map to known tags")
 
 
 
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
- (str(root_repo), str(categories_root), resolved.confidence_threshold),
 
 
 
 
 
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=(run_id, root_repo, categories_root, resolved.confidence_threshold, matched_tags,
675
- resolved.scan_recursive, resolved.experimental_media_enabled),
 
 
 
 
 
 
 
 
 
 
 
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
- force_cpu = os.getenv("FORCE_CPU_INFERENCE", "").strip().lower() in {"1", "true", "yes", "on"}
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
- def extract_scores(image_path: Path) -> dict[str, float]:
217
- from imgutils.tagging import get_mldanbooru_tags
 
 
 
 
 
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 _extract_scores_from_pil_image(image: Image.Image) -> dict[str, float]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- if isinstance(raw, list):
260
- for item in raw:
261
- if isinstance(item, (list, tuple)) and len(item) >= 2:
262
- scores[str(item[0])] = float(item[1])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
263
  return scores
264
 
265
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
266
  def extract_scores_with_experimental_media(
267
- image_path: Path, experimental_media_enabled: bool = False
 
 
 
 
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(image_path)
 
 
 
 
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(frame)
 
 
 
 
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(frame_path)
 
 
 
 
 
301
 
 
 
 
 
 
 
 
 
302
 
303
- def extract_scores_batch(image_paths: list[Path]) -> list[dict[str, float]]:
 
 
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
- from imgutils.tagging import get_mldanbooru_tags
312
-
313
- try:
314
- raw = get_mldanbooru_tags(
315
- [str(p) for p in image_paths],
316
- threshold=0.0,
317
- size=448,
318
- keep_ratio=True,
319
- drop_overlap=False,
320
- use_real_name=False,
321
  )
322
- except TypeError as err:
323
- # Current imgutils build treats list input as invalid image type.
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
- # If the backend or library returns an unexpected shape, fall back to per-image inference.
349
- logger.warning("batch inference unsupported format; falling back to per-image path")
350
- _BATCH_INFERENCE_SUPPORTED = False
351
- return [extract_scores(p) for p in image_paths]
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 AppSettings(
427
- root_repo=root_repo if root_repo is not None else current.root_repo,
428
- categories_root=categories_root if categories_root is not None else current.categories_root,
429
- confidence_threshold=(
430
- confidence_threshold if confidence_threshold is not None else current.confidence_threshold
431
- ),
432
- default_migrate_mode=current.default_migrate_mode,
433
- scan_recursive=current.scan_recursive,
434
- experimental_media_enabled=current.experimental_media_enabled,
 
 
 
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 _p: {"1girl": 0.91})
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 _p: {"1girl": 0.92})
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 _p: {"1girl": 0.99, "monster_girl": 0.85, "slime_girl": 0.82},
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 _p: {"1girl": 0.92, "solo": 0.88})
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 _p: {"1girl": 0.9})
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 Workflow</title>
 
 
 
 
 
 
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.getSettings()
 
61
  .then((data) => {
62
- setSettings(data);
 
 
 
 
 
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.getProviders()
 
72
  .then((info) => setProviderInfo(info))
73
  .catch(() => setProviderInfo(null));
74
  }, []);
75
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  useEffect(() => {
77
  let cancelled = false;
78
  const timer = setTimeout(() => {
79
- api.getTags(tagQuery, 50)
 
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
- setSettings(saved);
 
 
 
 
 
 
 
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
- <h1>Image Classifier Workflow</h1>
353
- <p>
354
- Configure source + category roots, run tagging, review predictions, then approve and
355
- migrate using move/copy.
356
- </p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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="stats">
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="card">
373
- <h2>Configuration</h2>
374
- <form onSubmit={handleSaveSettings} className="grid">
375
- <label>
376
- Root Repository
377
- <input
378
- value={settings.root_repo}
379
- onChange={(e) => setSettings({ ...settings, root_repo: e.target.value })}
380
- />
381
- </label>
382
- <label>
383
- Categories Root
384
- <input
385
- value={settings.categories_root}
386
- onChange={(e) => setSettings({ ...settings, categories_root: e.target.value })}
387
- />
388
- </label>
389
- <label>
390
- Confidence Threshold
391
- <input
392
- type="number"
393
- step="0.01"
394
- min="0"
395
- max="1"
396
- value={settings.confidence_threshold}
397
- onChange={(e) =>
398
- setSettings({ ...settings, confidence_threshold: Number(e.target.value) })
399
- }
400
- />
401
- </label>
402
- <label>
403
- Default Migrate Mode
404
- <select
405
- value={settings.default_migrate_mode}
406
- onChange={(e) => setSettings({ ...settings, default_migrate_mode: e.target.value })}
407
- >
408
- <option value="copy">copy</option>
409
- <option value="move">move</option>
410
- </select>
411
- </label>
412
- <label style={{ flexDirection: "row", alignItems: "center", gap: "0.5rem" }}>
413
- <input
414
- type="checkbox"
415
- checked={Boolean(settings.scan_recursive)}
416
- onChange={(e) => setSettings({ ...settings, scan_recursive: e.target.checked })}
417
- />
418
- Scan subfolders recursively
419
- </label>
420
- <label style={{ flexDirection: "row", alignItems: "center", gap: "0.5rem" }}>
421
- <input
422
- type="checkbox"
423
- checked={Boolean(settings.experimental_media_enabled)}
424
- onChange={(e) =>
425
- setSettings({ ...settings, experimental_media_enabled: e.target.checked })
426
- }
427
- />
428
- Experimental: classify GIF/videos using sampled frames
429
- </label>
430
- <button disabled={loading || opsLoading.saving}>Save Settings</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
431
  </form>
432
  </section>
433
 
434
- <section className="card">
435
- <h2>Run Classification</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
- </label>
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
- </select>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- <span>Selected tags:</span>
481
- {selectedTags.length === 0 && <span>none</span>}
482
- {selectedTags.map((tag) => (
483
- <span key={tag}>
484
- {tag} <button onClick={() => removeSelectedTag(tag)}>x</button>
485
- </span>
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="card">
502
- <h2>Review Queue (Run #{runId})</h2>
503
- {runStatus && (
504
- <div className="stats">
505
- <span>Status: {runStatus.status}</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
506
  <span>
507
- Progress: {runStatus.processed_images}/{runStatus.total_images} (
508
- {Number(runStatus.progress_pct || 0).toFixed(1)}%)
509
  </span>
510
- <span>Failed: {runStatus.failed_images}</span>
511
- {runStatus.inference_mode && <span>Inference mode: {runStatus.inference_mode}</span>}
512
- {runStatus.batch_size ? <span>Batch size: {runStatus.batch_size}</span> : null}
513
- {runStatus.avg_infer_ms_per_image !== null &&
514
- runStatus.avg_infer_ms_per_image !== undefined ? (
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}>Cancel Run</button>
 
 
528
  </div>
529
  )}
 
 
 
 
 
 
 
 
 
 
 
530
  <div className="stats">
531
- <span>Total: {stats.total}</span>
532
- <span>Needs Review: {stats.reviewNeeded}</span>
533
  <span>Approved: {stats.approved}</span>
534
  <span>Migrated: {stats.migrated}</span>
535
  </div>
536
  <div className="actions">
537
- <button disabled={opsLoading.applyingBatch} onClick={() => applyBatch("approved")}>
538
- Approve Selected
 
 
 
 
539
  </button>
540
- <button disabled={opsLoading.applyingBatch} onClick={() => applyBatch("rejected")}>
541
- Reject Selected
 
 
 
 
 
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..." : "Migrate Approved"}
549
  </button>
550
  </div>
551
 
@@ -554,13 +901,13 @@ function App() {
554
  <tr>
555
  <th>Select</th>
556
  <th>Image</th>
557
- <th>Primary (Selected)</th>
558
- <th>Score</th>
559
- <th>Secondary (Selected)</th>
560
  <th>Status</th>
561
- <th>Final Tag</th>
562
- <th>Debug Scores</th>
563
- <th>Review</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
- {(item.secondary_suggestions || [])
605
- .map((s) => `${s.tag} (${Number(s.score).toFixed(3)})`)
606
- .join(", ") || "-"}
 
 
 
 
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
- <button
629
- disabled={opsLoading.updatingStatus}
630
- onClick={() => updateStatus(item.id, "approved")}
631
- >
632
- Approve
633
- </button>
634
- <button
635
- disabled={opsLoading.updatingStatus}
636
- onClick={() => updateStatus(item.id, "rejected")}
637
- >
638
- Reject
639
- </button>
640
- <button
641
- disabled={opsLoading.updatingStatus}
642
- onClick={() => updateStatus(item.id, "reviewed")}
643
- >
644
- Reviewed
645
- </button>
 
 
 
 
 
 
 
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 &amp; 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: Inter, Arial, sans-serif;
4
- background: #f4f6fb;
5
- color: #1d2433;
 
 
 
 
6
  }
7
 
8
  .container {
9
- max-width: 1200px;
10
  margin: 0 auto;
11
- padding: 24px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  }
13
 
14
- .card {
15
- background: #ffffff;
16
- border-radius: 12px;
17
- padding: 16px;
 
 
 
 
 
 
18
  margin-bottom: 16px;
19
- box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- padding: 8px;
39
- border: 1px solid #ccd1dc;
 
40
  border-radius: 8px;
 
 
 
41
  }
42
 
43
  button {
44
  cursor: pointer;
45
- background: #1d4ed8;
46
  border: none;
47
  color: #fff;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  }
49
 
50
  .stats {
51
  display: flex;
52
- gap: 16px;
 
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 #e4e8ef;
71
- padding: 6px;
72
  text-align: left;
73
  vertical-align: top;
74
  }
75
 
 
 
 
 
 
 
 
 
76
  .needs-review {
77
- background: #fff8e7;
 
 
 
 
 
78
  }
79
 
80
  .error {
81
- background: #ffe7e7;
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 #e4e8ef;
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: #0f172a;
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
+ }