Dinamush Cursor commited on
Commit
d4fbdfb
·
1 Parent(s): df7fff2

feat: hybrid ML allowlist rescue on WD needs-review

Browse files

Co-authored-by: Cursor <cursoragent@cursor.com>

backend/app/api.py CHANGED
@@ -58,6 +58,7 @@ from .taxonomy import (
58
  resolve_taxonomy_folder,
59
  taxonomy_folder_names,
60
  )
 
61
  from .providers import probe_execution_providers
62
  from .storage import execute, fetch_all, fetch_one, from_json, to_json
63
 
@@ -100,6 +101,58 @@ def _assignment_noise_floor(confidence_threshold: float) -> float:
100
  return max(0.15, float(confidence_threshold) * 0.5)
101
 
102
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  def _now_iso() -> str:
104
  return datetime.now(timezone.utc).isoformat()
105
 
@@ -634,6 +687,7 @@ def _infer_one_image(
634
  wd_general_threshold: float = 0.35,
635
  experimental_style_detector_enabled: bool = False,
636
  hybrid_real_life: bool = False,
 
637
  ) -> _ImageInferenceResult:
638
  if hybrid_real_life:
639
  return _infer_hybrid_one_image(
@@ -660,7 +714,7 @@ def _infer_one_image(
660
  tagger_model=tagger_model,
661
  wd_general_threshold=wd_general_threshold,
662
  )
663
- return _classify_from_scores(
664
  image_path,
665
  scores,
666
  matched_tags,
@@ -668,6 +722,16 @@ def _infer_one_image(
668
  experimental_style_detector_enabled=experimental_style_detector_enabled,
669
  hybrid_real_life=False,
670
  )
 
 
 
 
 
 
 
 
 
 
671
  except Exception as err:
672
  if _is_provider_related_error(err):
673
  logger.exception("inference_provider_failure image=%s", image_path)
@@ -695,6 +759,7 @@ def _infer_batch_with_fallback(
695
  wd_general_threshold: float = 0.35,
696
  experimental_style_detector_enabled: bool = False,
697
  hybrid_real_life: bool = False,
 
698
  ) -> tuple[list[_ImageInferenceResult], float, str]:
699
  if not image_paths:
700
  return [], 0.0, "none"
@@ -738,6 +803,7 @@ def _infer_batch_with_fallback(
738
  wd_general_threshold,
739
  experimental_style_detector_enabled,
740
  False,
 
741
  )
742
  ]
743
  elapsed_ms = (time.perf_counter() - start) * 1000.0
@@ -757,6 +823,7 @@ def _infer_batch_with_fallback(
757
  wd_general_threshold,
758
  experimental_style_detector_enabled,
759
  False,
 
760
  )
761
  for p in image_paths
762
  ]
@@ -773,13 +840,22 @@ def _infer_batch_with_fallback(
773
  if len(scores_by_image) != len(image_paths):
774
  raise RuntimeError("Batch inference result count mismatch")
775
  rows = [
776
- _classify_from_scores(
777
- image_path,
778
- scores,
 
 
 
 
 
 
779
  matched_tags,
780
  confidence_threshold,
781
- experimental_style_detector_enabled=experimental_style_detector_enabled,
782
- hybrid_real_life=False,
 
 
 
783
  )
784
  for image_path, scores in zip(image_paths, scores_by_image)
785
  ]
@@ -802,6 +878,7 @@ def _infer_batch_with_fallback(
802
  wd_general_threshold,
803
  experimental_style_detector_enabled,
804
  False,
 
805
  )
806
  right_rows, right_ms, _ = _infer_batch_with_fallback(
807
  image_paths[mid:],
@@ -813,6 +890,7 @@ def _infer_batch_with_fallback(
813
  wd_general_threshold,
814
  experimental_style_detector_enabled,
815
  False,
 
816
  )
817
  return left_rows + right_rows, left_ms + right_ms, "batch_fallback"
818
  row = _infer_one_image(
@@ -824,6 +902,7 @@ def _infer_batch_with_fallback(
824
  wd_general_threshold,
825
  experimental_style_detector_enabled,
826
  False,
 
827
  )
828
  elapsed_ms = (time.perf_counter() - start) * 1000.0
829
  return [row], elapsed_ms, "single_fallback"
@@ -851,6 +930,7 @@ def _settings_from_db() -> AppSettings:
851
  experimental_style_detector_enabled=bool(
852
  row.get("experimental_style_detector_enabled", 0)
853
  ),
 
854
  selected_tags=selected_tags,
855
  max_inference_workers=int(row.get("max_inference_workers") or 2),
856
  inference_batch_size=int(row.get("inference_batch_size") or 4),
@@ -1026,6 +1106,7 @@ def _execute_run(
1026
  wd_general_threshold: float = 0.35,
1027
  experimental_style_detector_enabled: bool = False,
1028
  real_life_filter: bool = False,
 
1029
  ) -> None:
1030
  try:
1031
  # Mark running immediately so clients can cancel during provider probe / scan.
@@ -1174,6 +1255,7 @@ def _execute_run(
1174
  wd_general_threshold,
1175
  experimental_style_detector_enabled,
1176
  real_life_filter,
 
1177
  )
1178
  pending[future] = next_batch
1179
 
@@ -1384,6 +1466,7 @@ def _execute_reclassify(
1384
  tagger_model: str = "wd_eva02_large",
1385
  wd_general_threshold: float = 0.35,
1386
  experimental_style_detector_enabled: bool = False,
 
1387
  ) -> None:
1388
  try:
1389
  # Claim already set status=running; only refresh bookkeeping here.
@@ -1513,6 +1596,8 @@ def _execute_reclassify(
1513
  tagger_model,
1514
  wd_general_threshold,
1515
  experimental_style_detector_enabled,
 
 
1516
  )
1517
  pending[future] = next_batch
1518
 
@@ -1671,7 +1756,7 @@ def save_settings(payload: SaveSettingsRequest) -> AppSettings:
1671
  UPDATE settings
1672
  SET root_repo = ?, categories_root = ?, confidence_threshold = ?,
1673
  default_migrate_mode = ?, scan_recursive = ?, experimental_media_enabled = ?,
1674
- experimental_style_detector_enabled = ?,
1675
  selected_tags_json = ?, max_inference_workers = ?, inference_batch_size = ?,
1676
  force_cpu_inference = ?, tagger_model = ?, wd_general_threshold = ?
1677
  WHERE id = 1
@@ -1684,6 +1769,7 @@ def save_settings(payload: SaveSettingsRequest) -> AppSettings:
1684
  1 if payload.scan_recursive else 0,
1685
  1 if payload.experimental_media_enabled else 0,
1686
  1 if payload.experimental_style_detector_enabled else 0,
 
1687
  to_json(cleaned_tags),
1688
  int(payload.max_inference_workers),
1689
  int(payload.inference_batch_size),
@@ -1870,6 +1956,7 @@ def start_run(payload: StartRunRequest) -> StartRunResponse:
1870
  "wd_general_threshold": current.wd_general_threshold,
1871
  "experimental_style_detector_enabled": style_detector_enabled,
1872
  "real_life_filter": real_life_filter,
 
1873
  },
1874
  daemon=True,
1875
  )
@@ -2010,6 +2097,7 @@ def reclassify_run(run_id: int, payload: ReclassifyRequest) -> ReclassifyRespons
2010
  payload.tagger_model,
2011
  current.wd_general_threshold,
2012
  current.experimental_style_detector_enabled,
 
2013
  ),
2014
  daemon=True,
2015
  )
 
58
  resolve_taxonomy_folder,
59
  taxonomy_folder_names,
60
  )
61
+ from .hybrid_ml import merge_ml_allowlist_scores, should_run_hybrid_ml
62
  from .providers import probe_execution_providers
63
  from .storage import execute, fetch_all, fetch_one, from_json, to_json
64
 
 
101
  return max(0.15, float(confidence_threshold) * 0.5)
102
 
103
 
104
+ def _maybe_hybrid_ml_rescue(
105
+ result: _ImageInferenceResult,
106
+ matched_tags: set[str],
107
+ confidence_threshold: float,
108
+ experimental_media_enabled: bool,
109
+ tagger_model: str,
110
+ wd_general_threshold: float,
111
+ experimental_style_detector_enabled: bool,
112
+ hybrid_ml_on_review: bool,
113
+ ) -> _ImageInferenceResult:
114
+ """On WD needs_review, merge allowlisted ML scores and re-route."""
115
+ if not should_run_hybrid_ml(
116
+ enabled=hybrid_ml_on_review,
117
+ tagger_model=tagger_model,
118
+ needs_review=result.needs_review,
119
+ inference_failed=result.inference_failed,
120
+ ):
121
+ return result
122
+ try:
123
+ if experimental_media_enabled and is_experimental_media(result.image_path):
124
+ ml_scores = extract_scores_with_experimental_media(
125
+ result.image_path,
126
+ experimental_media_enabled,
127
+ tagger_model="ml_danbooru",
128
+ wd_general_threshold=wd_general_threshold,
129
+ )
130
+ else:
131
+ ml_scores = extract_scores(
132
+ result.image_path,
133
+ tagger_model="ml_danbooru",
134
+ wd_general_threshold=wd_general_threshold,
135
+ )
136
+ merged = merge_ml_allowlist_scores(result.scores, ml_scores)
137
+ rescued = _classify_from_scores(
138
+ result.image_path,
139
+ merged,
140
+ matched_tags,
141
+ confidence_threshold,
142
+ experimental_style_detector_enabled=experimental_style_detector_enabled,
143
+ hybrid_real_life=False,
144
+ )
145
+ note = "Hybrid ML allowlist rescue."
146
+ if rescued.reason:
147
+ rescued.reason = f"{rescued.reason} {note}"
148
+ elif rescued.needs_review != result.needs_review or rescued.primary_tag != result.primary_tag:
149
+ rescued.reason = note
150
+ return rescued
151
+ except Exception:
152
+ logger.exception("hybrid_ml_rescue_failed image=%s", result.image_path)
153
+ return result
154
+
155
+
156
  def _now_iso() -> str:
157
  return datetime.now(timezone.utc).isoformat()
158
 
 
687
  wd_general_threshold: float = 0.35,
688
  experimental_style_detector_enabled: bool = False,
689
  hybrid_real_life: bool = False,
690
+ hybrid_ml_on_review: bool = False,
691
  ) -> _ImageInferenceResult:
692
  if hybrid_real_life:
693
  return _infer_hybrid_one_image(
 
714
  tagger_model=tagger_model,
715
  wd_general_threshold=wd_general_threshold,
716
  )
717
+ result = _classify_from_scores(
718
  image_path,
719
  scores,
720
  matched_tags,
 
722
  experimental_style_detector_enabled=experimental_style_detector_enabled,
723
  hybrid_real_life=False,
724
  )
725
+ return _maybe_hybrid_ml_rescue(
726
+ result,
727
+ matched_tags,
728
+ confidence_threshold,
729
+ experimental_media_enabled,
730
+ tagger_model,
731
+ wd_general_threshold,
732
+ experimental_style_detector_enabled,
733
+ hybrid_ml_on_review,
734
+ )
735
  except Exception as err:
736
  if _is_provider_related_error(err):
737
  logger.exception("inference_provider_failure image=%s", image_path)
 
759
  wd_general_threshold: float = 0.35,
760
  experimental_style_detector_enabled: bool = False,
761
  hybrid_real_life: bool = False,
762
+ hybrid_ml_on_review: bool = False,
763
  ) -> tuple[list[_ImageInferenceResult], float, str]:
764
  if not image_paths:
765
  return [], 0.0, "none"
 
803
  wd_general_threshold,
804
  experimental_style_detector_enabled,
805
  False,
806
+ hybrid_ml_on_review,
807
  )
808
  ]
809
  elapsed_ms = (time.perf_counter() - start) * 1000.0
 
823
  wd_general_threshold,
824
  experimental_style_detector_enabled,
825
  False,
826
+ hybrid_ml_on_review,
827
  )
828
  for p in image_paths
829
  ]
 
840
  if len(scores_by_image) != len(image_paths):
841
  raise RuntimeError("Batch inference result count mismatch")
842
  rows = [
843
+ _maybe_hybrid_ml_rescue(
844
+ _classify_from_scores(
845
+ image_path,
846
+ scores,
847
+ matched_tags,
848
+ confidence_threshold,
849
+ experimental_style_detector_enabled=experimental_style_detector_enabled,
850
+ hybrid_real_life=False,
851
+ ),
852
  matched_tags,
853
  confidence_threshold,
854
+ experimental_media_enabled,
855
+ tagger_model,
856
+ wd_general_threshold,
857
+ experimental_style_detector_enabled,
858
+ hybrid_ml_on_review,
859
  )
860
  for image_path, scores in zip(image_paths, scores_by_image)
861
  ]
 
878
  wd_general_threshold,
879
  experimental_style_detector_enabled,
880
  False,
881
+ hybrid_ml_on_review,
882
  )
883
  right_rows, right_ms, _ = _infer_batch_with_fallback(
884
  image_paths[mid:],
 
890
  wd_general_threshold,
891
  experimental_style_detector_enabled,
892
  False,
893
+ hybrid_ml_on_review,
894
  )
895
  return left_rows + right_rows, left_ms + right_ms, "batch_fallback"
896
  row = _infer_one_image(
 
902
  wd_general_threshold,
903
  experimental_style_detector_enabled,
904
  False,
905
+ hybrid_ml_on_review,
906
  )
907
  elapsed_ms = (time.perf_counter() - start) * 1000.0
908
  return [row], elapsed_ms, "single_fallback"
 
930
  experimental_style_detector_enabled=bool(
931
  row.get("experimental_style_detector_enabled", 0)
932
  ),
933
+ hybrid_ml_on_review=bool(row.get("hybrid_ml_on_review", 1)),
934
  selected_tags=selected_tags,
935
  max_inference_workers=int(row.get("max_inference_workers") or 2),
936
  inference_batch_size=int(row.get("inference_batch_size") or 4),
 
1106
  wd_general_threshold: float = 0.35,
1107
  experimental_style_detector_enabled: bool = False,
1108
  real_life_filter: bool = False,
1109
+ hybrid_ml_on_review: bool = False,
1110
  ) -> None:
1111
  try:
1112
  # Mark running immediately so clients can cancel during provider probe / scan.
 
1255
  wd_general_threshold,
1256
  experimental_style_detector_enabled,
1257
  real_life_filter,
1258
+ False if real_life_filter else hybrid_ml_on_review,
1259
  )
1260
  pending[future] = next_batch
1261
 
 
1466
  tagger_model: str = "wd_eva02_large",
1467
  wd_general_threshold: float = 0.35,
1468
  experimental_style_detector_enabled: bool = False,
1469
+ hybrid_ml_on_review: bool = False,
1470
  ) -> None:
1471
  try:
1472
  # Claim already set status=running; only refresh bookkeeping here.
 
1596
  tagger_model,
1597
  wd_general_threshold,
1598
  experimental_style_detector_enabled,
1599
+ False,
1600
+ hybrid_ml_on_review,
1601
  )
1602
  pending[future] = next_batch
1603
 
 
1756
  UPDATE settings
1757
  SET root_repo = ?, categories_root = ?, confidence_threshold = ?,
1758
  default_migrate_mode = ?, scan_recursive = ?, experimental_media_enabled = ?,
1759
+ experimental_style_detector_enabled = ?, hybrid_ml_on_review = ?,
1760
  selected_tags_json = ?, max_inference_workers = ?, inference_batch_size = ?,
1761
  force_cpu_inference = ?, tagger_model = ?, wd_general_threshold = ?
1762
  WHERE id = 1
 
1769
  1 if payload.scan_recursive else 0,
1770
  1 if payload.experimental_media_enabled else 0,
1771
  1 if payload.experimental_style_detector_enabled else 0,
1772
+ 1 if payload.hybrid_ml_on_review else 0,
1773
  to_json(cleaned_tags),
1774
  int(payload.max_inference_workers),
1775
  int(payload.inference_batch_size),
 
1956
  "wd_general_threshold": current.wd_general_threshold,
1957
  "experimental_style_detector_enabled": style_detector_enabled,
1958
  "real_life_filter": real_life_filter,
1959
+ "hybrid_ml_on_review": bool(current.hybrid_ml_on_review),
1960
  },
1961
  daemon=True,
1962
  )
 
2097
  payload.tagger_model,
2098
  current.wd_general_threshold,
2099
  current.experimental_style_detector_enabled,
2100
+ current.hybrid_ml_on_review,
2101
  ),
2102
  daemon=True,
2103
  )
backend/app/hybrid_ml.py ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """WD-primary + ML allowlist rescue on needs_review.
2
+
3
+ Benchmark-driven: ML wins recall on character/act cues (loli, fellatio,
4
+ tentacles, monster_girl, …) but has higher Voyeur soft-tag FPR. Never merge
5
+ those soft tags from ML into WD scores.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ # Tags where ML recall@0.35 beat WD and FP rates stayed low on the curated suite.
11
+ HYBRID_ML_ALLOWLIST: frozenset[str] = frozenset(
12
+ {
13
+ # Character / age
14
+ "loli",
15
+ "shota",
16
+ # Oral
17
+ "fellatio",
18
+ "irrumatio",
19
+ "deepthroat",
20
+ "licking_penis",
21
+ "cooperative_fellatio",
22
+ "after_fellatio",
23
+ # Creampie / nakadashi evidence
24
+ "nakadashi",
25
+ "cum_in_pussy",
26
+ "internal_cumshot",
27
+ "after_vaginal",
28
+ # Tentacle act (not bare "tentacles")
29
+ "tentacle_sex",
30
+ "consensual_tentacles",
31
+ "tentacles_on_male",
32
+ "tentacle_pit",
33
+ "tentacles_under_clothes",
34
+ # Monster-girl catch-all identity
35
+ "monster_girl",
36
+ "monster_boy",
37
+ "slime_girl",
38
+ "slime_(creature)",
39
+ "lamia",
40
+ "harpy",
41
+ "scylla",
42
+ "spider_girl",
43
+ "arthropod_girl",
44
+ "dragon_girl",
45
+ "plant_girl",
46
+ "fish_girl",
47
+ "shark_girl",
48
+ "frog_girl",
49
+ "bird_girl",
50
+ "moth_girl",
51
+ "centaur",
52
+ "mermaid",
53
+ "female_goblin",
54
+ "minotaur",
55
+ "orc",
56
+ "cyclops",
57
+ "oni",
58
+ "traditional_youkai",
59
+ "demon_girl",
60
+ "vampire",
61
+ "werewolf",
62
+ "monsterification",
63
+ # Android catch-all identity
64
+ "android",
65
+ "robot_girl",
66
+ "humanoid_robot",
67
+ "cyborg",
68
+ "mecha_musume",
69
+ "mechanization",
70
+ # Other preferred folders with strong ML recall
71
+ "furry",
72
+ "pokemon_(creature)",
73
+ "bestiality",
74
+ "animal_penis",
75
+ "incest",
76
+ "twincest",
77
+ "netorare",
78
+ "cheating_(relationship)",
79
+ "impregnation",
80
+ "fertilization",
81
+ "ovum",
82
+ }
83
+ )
84
+
85
+ # Never trust ML for these even if someone expands the allowlist later.
86
+ HYBRID_ML_DENYLIST: frozenset[str] = frozenset(
87
+ {
88
+ "cleavage",
89
+ "nude",
90
+ "completely_nude",
91
+ "pussy",
92
+ "ass",
93
+ "breasts",
94
+ "large_breasts",
95
+ "huge_breasts",
96
+ "medium_breasts",
97
+ "small_breasts",
98
+ "nipples",
99
+ "areolae",
100
+ "panties",
101
+ "underwear",
102
+ "bra",
103
+ "bikini",
104
+ "swimsuit",
105
+ "lingerie",
106
+ "upskirt",
107
+ "see-through",
108
+ "see_through",
109
+ "cameltoe",
110
+ "underboob",
111
+ "sideboob",
112
+ "topless",
113
+ "bottomless",
114
+ "no_panties",
115
+ "spread_legs",
116
+ "from_below",
117
+ "looking_at_viewer",
118
+ }
119
+ )
120
+
121
+
122
+ def should_run_hybrid_ml(
123
+ *,
124
+ enabled: bool,
125
+ tagger_model: str,
126
+ needs_review: bool,
127
+ inference_failed: bool,
128
+ ) -> bool:
129
+ if not enabled or not needs_review or inference_failed:
130
+ return False
131
+ return str(tagger_model or "").startswith("wd_")
132
+
133
+
134
+ def merge_ml_allowlist_scores(
135
+ wd_scores: dict[str, float],
136
+ ml_scores: dict[str, float],
137
+ allowlist: frozenset[str] | set[str] | None = None,
138
+ denylist: frozenset[str] | set[str] | None = None,
139
+ ) -> dict[str, float]:
140
+ """Copy WD scores; for allowlisted tags take max(WD, ML). Deny Voyeur soft tags."""
141
+ allow = allowlist if allowlist is not None else HYBRID_ML_ALLOWLIST
142
+ deny = denylist if denylist is not None else HYBRID_ML_DENYLIST
143
+ merged = {str(k): float(v) for k, v in wd_scores.items()}
144
+ for tag, score in ml_scores.items():
145
+ key = str(tag)
146
+ if key in deny or key not in allow:
147
+ continue
148
+ try:
149
+ ml_val = float(score)
150
+ except (TypeError, ValueError):
151
+ continue
152
+ prev = float(merged.get(key, 0.0))
153
+ if ml_val > prev:
154
+ merged[key] = ml_val
155
+ return merged
backend/app/schemas.py CHANGED
@@ -20,6 +20,9 @@ class AppSettings(BaseModel):
20
  experimental_media_enabled: bool = False
21
  # Dedicated real-vs-anime ONNX gate (imgutils / deepghs anime_real_cls).
22
  experimental_style_detector_enabled: bool = False
 
 
 
23
  # Shuck3r-style persisted preferences (survive reload / restart).
24
  selected_tags: list[str] = Field(default_factory=list)
25
  # Shared ORT run lock; preprocess overlaps across workers. Prefer 2 on GPU.
 
20
  experimental_media_enabled: bool = False
21
  # Dedicated real-vs-anime ONNX gate (imgutils / deepghs anime_real_cls).
22
  experimental_style_detector_enabled: bool = False
23
+ # When primary tagger is WD and an item needs review, re-run ML-Danbooru and
24
+ # merge only allowlisted high-recall tags (never Voyeur soft cues).
25
+ hybrid_ml_on_review: bool = True
26
  # Shuck3r-style persisted preferences (survive reload / restart).
27
  selected_tags: list[str] = Field(default_factory=list)
28
  # Shared ORT run lock; preprocess overlaps across workers. Prefer 2 on GPU.
backend/app/storage.py CHANGED
@@ -82,6 +82,7 @@ def _ensure_settings_columns(conn: sqlite3.Connection) -> None:
82
  "scan_recursive": "INTEGER NOT NULL DEFAULT 1",
83
  "experimental_media_enabled": "INTEGER NOT NULL DEFAULT 0",
84
  "experimental_style_detector_enabled": "INTEGER NOT NULL DEFAULT 0",
 
85
  "selected_tags_json": "TEXT NOT NULL DEFAULT '[]'",
86
  "max_inference_workers": "INTEGER NOT NULL DEFAULT 2",
87
  "inference_batch_size": "INTEGER NOT NULL DEFAULT 4",
 
82
  "scan_recursive": "INTEGER NOT NULL DEFAULT 1",
83
  "experimental_media_enabled": "INTEGER NOT NULL DEFAULT 0",
84
  "experimental_style_detector_enabled": "INTEGER NOT NULL DEFAULT 0",
85
+ "hybrid_ml_on_review": "INTEGER NOT NULL DEFAULT 1",
86
  "selected_tags_json": "TEXT NOT NULL DEFAULT '[]'",
87
  "max_inference_workers": "INTEGER NOT NULL DEFAULT 2",
88
  "inference_batch_size": "INTEGER NOT NULL DEFAULT 4",
backend/tests/test_api_run_progress.py CHANGED
@@ -40,18 +40,21 @@ def test_settings_round_trip_includes_tagger_model(tmp_path: Path):
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_start_run_missing_root_returns_400(tmp_path: Path):
 
40
  "force_cpu_inference": False,
41
  "tagger_model": "wd_eva02_large",
42
  "wd_general_threshold": 0.4,
43
+ "hybrid_ml_on_review": False,
44
  }
45
  put_resp = client.put("/api/settings", json=payload)
46
  put_resp.raise_for_status()
47
  saved = put_resp.json()
48
  assert saved["tagger_model"] == "wd_eva02_large"
49
  assert saved["wd_general_threshold"] == 0.4
50
+ assert saved["hybrid_ml_on_review"] is False
51
  get_resp = client.get("/api/settings")
52
  get_resp.raise_for_status()
53
  loaded = get_resp.json()
54
  assert loaded["tagger_model"] == "wd_eva02_large"
55
  assert loaded["wd_general_threshold"] == 0.4
56
  assert loaded["max_inference_workers"] == 2
57
+ assert loaded["hybrid_ml_on_review"] is False
58
 
59
 
60
  def test_start_run_missing_root_returns_400(tmp_path: Path):
backend/tests/test_hybrid_ml.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hybrid ML-on-review: allowlist merge must raise preferred tags without Voyeur noise."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+ from app.hybrid_ml import (
8
+ HYBRID_ML_ALLOWLIST,
9
+ merge_ml_allowlist_scores,
10
+ should_run_hybrid_ml,
11
+ )
12
+ from app.taxonomy import choose_best_destination
13
+
14
+
15
+ def test_merge_raises_allowlisted_ml_score_with_max() -> None:
16
+ wd = {"1girl": 0.99, "loli": 0.2}
17
+ ml = {"1girl": 0.5, "loli": 0.88}
18
+ merged = merge_ml_allowlist_scores(wd, ml)
19
+ assert merged["loli"] == pytest.approx(0.88)
20
+ assert merged["1girl"] == pytest.approx(0.99)
21
+
22
+
23
+ def test_merge_ignores_voyeur_soft_tags_from_ml() -> None:
24
+ wd = {"1girl": 0.9, "cleavage": 0.4}
25
+ ml = {"cleavage": 0.99, "nude": 0.95, "pussy": 0.9, "fellatio": 0.8}
26
+ merged = merge_ml_allowlist_scores(wd, ml)
27
+ assert merged["cleavage"] == pytest.approx(0.4)
28
+ assert "nude" not in merged
29
+ assert "pussy" not in merged
30
+ assert merged["fellatio"] == pytest.approx(0.8)
31
+
32
+
33
+ def test_merge_does_not_inject_non_allowlist_tags() -> None:
34
+ wd = {"solo": 0.9}
35
+ ml = {"blue_eyes": 0.99, "monster_girl": 0.7}
36
+ merged = merge_ml_allowlist_scores(wd, ml)
37
+ assert "blue_eyes" not in merged
38
+ assert merged["monster_girl"] == pytest.approx(0.7)
39
+ assert merged["solo"] == pytest.approx(0.9)
40
+
41
+
42
+ def test_allowlist_covers_preferred_recall_tags() -> None:
43
+ for tag in (
44
+ "loli",
45
+ "shota",
46
+ "fellatio",
47
+ "irrumatio",
48
+ "cum_in_pussy",
49
+ "tentacle_sex",
50
+ "monster_girl",
51
+ "slime_girl",
52
+ "android",
53
+ "robot_girl",
54
+ ):
55
+ assert tag in HYBRID_ML_ALLOWLIST, tag
56
+
57
+
58
+ def test_merged_scores_can_rescue_routing() -> None:
59
+ """WD miss + ML hit on allowlisted act should route after merge."""
60
+ selected = {"fellatio", "Voyeur", "sex"}
61
+ wd = {"sex": 0.4, "cleavage": 0.9}
62
+ ml = {"fellatio": 0.85, "cleavage": 0.99}
63
+ folder_before, _, _ = choose_best_destination(wd, selected)
64
+ assert folder_before != "fellatio"
65
+ merged = merge_ml_allowlist_scores(wd, ml)
66
+ folder_after, score, _ = choose_best_destination(merged, selected)
67
+ assert folder_after == "fellatio"
68
+ assert score >= 0.85
69
+
70
+
71
+ def test_should_run_hybrid_ml_only_for_wd_review() -> None:
72
+ assert should_run_hybrid_ml(
73
+ enabled=True, tagger_model="wd_swinv2_v3", needs_review=True, inference_failed=False
74
+ )
75
+ assert not should_run_hybrid_ml(
76
+ enabled=True, tagger_model="ml_danbooru", needs_review=True, inference_failed=False
77
+ )
78
+ assert not should_run_hybrid_ml(
79
+ enabled=True, tagger_model="wd_swinv2_v3", needs_review=False, inference_failed=False
80
+ )
81
+ assert not should_run_hybrid_ml(
82
+ enabled=False, tagger_model="wd_swinv2_v3", needs_review=True, inference_failed=False
83
+ )
84
+ assert not should_run_hybrid_ml(
85
+ enabled=True, tagger_model="wd_swinv2_v3", needs_review=True, inference_failed=True
86
+ )
87
+
88
+
89
+ def test_maybe_hybrid_ml_rescue_reclassifies(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
90
+ from pathlib import Path
91
+
92
+ from app.api import _ImageInferenceResult, _maybe_hybrid_ml_rescue
93
+
94
+ path = tmp_path / "x.jpg"
95
+ path.write_bytes(b"not-an-image")
96
+ base = _ImageInferenceResult(
97
+ image_path=path,
98
+ scores={"sex": 0.4, "cleavage": 0.9},
99
+ primary_tag=None,
100
+ primary_score=None,
101
+ secondary=[],
102
+ needs_review=True,
103
+ reason="No matching tags found among selected tags.",
104
+ inference_failed=False,
105
+ )
106
+
107
+ monkeypatch.setattr(
108
+ "app.api.extract_scores",
109
+ lambda *a, **k: {"fellatio": 0.9, "cleavage": 0.99},
110
+ )
111
+ out = _maybe_hybrid_ml_rescue(
112
+ base,
113
+ {"fellatio", "Voyeur", "sex"},
114
+ 0.6,
115
+ False,
116
+ "wd_swinv2_v3",
117
+ 0.35,
118
+ False,
119
+ True,
120
+ )
121
+ assert out.primary_tag == "fellatio"
122
+ assert out.needs_review is False
123
+ assert out.scores["fellatio"] == pytest.approx(0.9)
124
+ # ML Voyeur soft tag must not overwrite WD.
125
+ assert out.scores["cleavage"] == pytest.approx(0.9)
frontend/src/App.jsx CHANGED
@@ -10,6 +10,7 @@ const DEFAULT_SETTINGS = {
10
  scan_recursive: true,
11
  experimental_media_enabled: false,
12
  experimental_style_detector_enabled: false,
 
13
  selected_tags: [],
14
  max_inference_workers: 2,
15
  inference_batch_size: 8,
@@ -906,6 +907,20 @@ function App() {
906
  Experimental: real vs anime style gate (dedicated ONNX →{" "}
907
  <code>real_life</code>; uncertain → needs review)
908
  </label>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
909
  </div>
910
  </fieldset>
911
 
 
10
  scan_recursive: true,
11
  experimental_media_enabled: false,
12
  experimental_style_detector_enabled: false,
13
+ hybrid_ml_on_review: true,
14
  selected_tags: [],
15
  max_inference_workers: 2,
16
  inference_batch_size: 8,
 
907
  Experimental: real vs anime style gate (dedicated ONNX →{" "}
908
  <code>real_life</code>; uncertain → needs review)
909
  </label>
910
+ <label className="inline-check">
911
+ <input
912
+ type="checkbox"
913
+ checked={Boolean(settings.hybrid_ml_on_review)}
914
+ onChange={(e) =>
915
+ setSettings({
916
+ ...settings,
917
+ hybrid_ml_on_review: e.target.checked,
918
+ })
919
+ }
920
+ />
921
+ Hybrid: on needs-review (WD primary), re-run ML-Danbooru and merge
922
+ allowlisted high-recall tags only (skips Voyeur soft cues)
923
+ </label>
924
  </div>
925
  </fieldset>
926