Dinamush Cursor commited on
Commit ·
d4fbdfb
1
Parent(s): df7fff2
feat: hybrid ML allowlist rescue on WD needs-review
Browse filesCo-authored-by: Cursor <cursoragent@cursor.com>
- backend/app/api.py +95 -7
- backend/app/hybrid_ml.py +155 -0
- backend/app/schemas.py +3 -0
- backend/app/storage.py +1 -0
- backend/tests/test_api_run_progress.py +3 -0
- backend/tests/test_hybrid_ml.py +125 -0
- frontend/src/App.jsx +15 -0
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 |
-
|
| 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 |
-
|
| 777 |
-
|
| 778 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 779 |
matched_tags,
|
| 780 |
confidence_threshold,
|
| 781 |
-
|
| 782 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
|