thr3shr / backend /tests /test_classify_accuracy.py
Dinamush
feat: taxonomy catch-alls, scan/migrate fixes, and tag recall/FP benchmarks
df7fff2
Raw
History Blame Contribute Delete
8.54 kB
"""Accuracy audit for taxonomy routing + classify gates (score fixtures)."""
from __future__ import annotations
from pathlib import Path
import pytest
from PIL import Image
from app.api import _classify_from_scores
from app.taxonomy import choose_best_destination, reload_taxonomy
SELECTED_ALL = {
"fertilization",
"NTR",
"incest",
"nakadashi",
"fellatio",
"paizuri",
"footjob",
"tentacles",
"sex",
"loli",
"shota",
"monster_girl",
"android",
"furry",
"Pokemon",
"bestiality",
"Voyeur",
}
# Fallback homes are opt-in; they yield to every folder in SELECTED_ALL.
SELECTED_FALLBACKS = {"SFW", "comic", "scenery"}
@pytest.fixture(autouse=True)
def _reload_default_taxonomy() -> None:
reload_taxonomy()
@pytest.mark.parametrize(
"scores,expected",
[
({"loli": 0.92, "flat_chest": 0.99, "lolita_fashion": 0.95}, "loli"),
({"shota": 0.88, "1boy": 0.99, "child": 0.9}, "shota"),
({"netorare": 0.81, "caught": 0.99}, "NTR"),
({"incest": 0.77, "siblings": 0.99}, "incest"),
({"internal_cumshot": 0.9, "cum_in_pussy": 0.95}, "nakadashi"),
({"fertilization": 0.8, "cum_in_pussy": 0.99}, "fertilization"),
({"impregnation": 0.85, "pregnant": 0.99}, "fertilization"),
({"irrumatio": 0.9}, "fellatio"),
({"monster_girl": 0.9, "horns": 0.99, "wings": 0.98}, "monster_girl"),
({"slime_girl": 0.86}, "monster_girl"),
({"lamia": 0.9}, "monster_girl"),
({"scylla": 0.88}, "monster_girl"),
({"shark_girl": 0.85}, "monster_girl"),
({"moth_girl": 0.84}, "monster_girl"),
({"traditional_youkai": 0.9}, "monster_girl"),
({"monsterification": 0.8}, "monster_girl"),
({"cow_girl": 0.99}, None),
({"extra_eyes": 0.95}, None),
({"android": 0.9}, "android"),
({"robot_girl": 0.88}, "android"),
({"cyborg": 0.85}, "android"),
({"mecha_musume": 0.84}, "android"),
({"mecha": 0.99}, None),
({"mechanical_arms": 0.95}, None),
({"furry": 0.9, "animal_ears": 0.99}, "furry"),
({"pokemon_(creature)": 0.91, "pokemon_ears": 0.99}, "Pokemon"),
# False-positive clusters must not route
({"lolita_fashion": 0.99, "gothic_lolita": 0.98, "flat_chest": 0.97}, None),
({"1boy": 0.99, "male_focus": 0.98, "otoko_no_ko": 0.9}, None),
({"animal_ears": 0.99, "fake_animal_ears": 0.9}, None),
({"siblings": 0.99}, None),
({"pregnant": 0.99}, None),
# voyeurism is Voyeur/caught evidence (not NTR); caught/watching alone must not route
({"voyeurism": 0.99, "caught": 0.9}, "Voyeur/caught"),
({"watching": 0.99, "caught": 0.9}, None),
({"horns": 0.99, "wings": 0.98, "tail": 0.97}, None),
({"oral": 0.99}, None),
({"pantyshot": 0.9}, "Voyeur/panties"),
({"spread_pussy": 0.9}, "Voyeur/pussy"),
# Surface cum is tease, not a hard act, so the pussy tease folder keeps it.
({"pussy": 0.88, "cum": 0.61}, "Voyeur/pussy"),
({"soles": 0.9, "feet": 0.85}, "Voyeur/feet"),
({"upskirt": 0.9, "loli": 0.6}, "loli"),
# Voyeur sub-folder funnel
({"highleg_leotard": 0.9, "covered_nipples": 0.7}, "Voyeur/leotard"),
({"bikini": 0.95}, "Voyeur/swimsuit"),
({"nude": 0.9, "nipples": 0.88}, "Voyeur/nude"),
({"lingerie": 0.9}, "Voyeur/lingerie"),
({"pantyhose": 0.95, "zettai_ryouiki": 0.8}, "Voyeur/legwear"),
({"undressing": 0.9, "open_clothes": 0.7}, "Voyeur/undressing"),
({"see-through": 0.9, "nipples": 0.6}, "Voyeur/see_through"),
({"exhibitionism": 0.9}, "Voyeur/public"),
({"cameltoe": 0.9}, "Voyeur"),
# see-through needs body exposure; crystalline characters must not match
({"see-through": 0.85, "androgynous": 0.9, "other_focus": 0.93}, None),
# New act folders
({"sex": 0.95, "vaginal": 0.9, "hetero": 0.99}, "sex"),
({"paizuri": 0.9, "breasts": 0.99}, "paizuri"),
({"footjob": 0.9, "feet": 0.99}, "footjob"),
({"tentacle_sex": 0.9, "tentacles": 0.85}, "tentacles"),
({"consensual_tentacles": 0.88}, "tentacles"),
({"tentacles": 0.95}, None),
({"pussy": 0.9, "fingering": 0.9}, "sex"),
({"sex": 0.95, "fellatio": 0.7}, "fellatio"),
({"sex": 0.95, "cum_in_pussy": 0.6}, "nakadashi"),
({"sex": 0.95, "tentacle_sex": 0.6}, "tentacles"),
],
)
def test_taxonomy_routing_matrix(scores: dict[str, float], expected: str | None) -> None:
folder, _score, _secondary = choose_best_destination(scores, SELECTED_ALL)
assert folder == expected
@pytest.mark.parametrize(
"scores,expected",
[
({"1girl": 0.99, "solo": 0.98, "smile": 0.9}, "SFW"),
({"comic": 0.9, "speech_bubble": 0.8, "1girl": 0.85}, "comic"),
({"scenery": 0.8, "no_humans": 0.95}, "scenery"),
# Fallbacks yield to any real destination.
({"1girl": 0.99, "pantyshot": 0.4}, "Voyeur/panties"),
({"1girl": 0.99, "loli": 0.3}, "loli"),
({"1girl": 0.99, "nipples": 0.9}, "Voyeur/nude"),
# Explicit content with no destination goes to review, never to SFW.
({"1girl": 0.99, "censored": 0.6, "penis": 0.2}, None),
],
)
def test_fallback_routing_matrix(scores: dict[str, float], expected: str | None) -> None:
folder, _score, _secondary = choose_best_destination(
scores, SELECTED_ALL | SELECTED_FALLBACKS
)
assert folder == expected
def test_classify_gate_clears_weak_taxonomy_winner(tmp_path: Path) -> None:
result = _classify_from_scores(
tmp_path / "x.jpg",
{"loli": 0.2, "1girl": 0.99},
{"loli", "shota"},
confidence_threshold=0.6,
)
assert result.primary_tag is None
assert result.needs_review is True
assert result.secondary[0]["tag"] == "loli"
def test_classify_keeps_mid_character_primary_for_review(tmp_path: Path) -> None:
"""Character hits below confidence stay as primary (needs_review), not cleared."""
result = _classify_from_scores(
tmp_path / "x.jpg",
{"loli": 0.42, "1girl": 0.99},
{"loli", "shota", "NTR", "fellatio"},
confidence_threshold=0.6,
)
assert result.primary_tag == "loli"
assert result.primary_score == 0.42
assert result.needs_review is True
assert "threshold" in (result.reason or "").lower()
def test_classify_still_clears_mid_act_primary(tmp_path: Path) -> None:
result = _classify_from_scores(
tmp_path / "x.jpg",
{"fellatio": 0.42, "1girl": 0.99},
{"loli", "fellatio"},
confidence_threshold=0.6,
)
assert result.primary_tag is None
assert result.needs_review is True
assert result.secondary[0]["tag"] == "fellatio"
def test_classify_assigns_strong_taxonomy_winner(tmp_path: Path) -> None:
result = _classify_from_scores(
tmp_path / "x.jpg",
{"loli": 0.91, "flat_chest": 0.95, "shota": 0.4},
{"loli", "shota", "furry"},
confidence_threshold=0.6,
)
assert result.primary_tag == "loli"
assert result.primary_score == 0.91
assert result.needs_review is False
def test_priority_fertilization_over_nakadashi_on_tie(tmp_path: Path) -> None:
result = _classify_from_scores(
tmp_path / "x.jpg",
{"fertilization": 0.8, "internal_cumshot": 0.8},
{"fertilization", "nakadashi"},
confidence_threshold=0.6,
)
assert result.primary_tag == "fertilization"
def test_model_smoke_nonempty_scores_all_taggers(tmp_path: Path) -> None:
"""Smoke: each tagger returns a non-empty score dict on a tiny RGB image.
This is not semantic accuracy (no labels); it catches broken sessions /
empty-output regressions across models.
"""
pytest.importorskip("imgutils")
from app.services import extract_scores
img = tmp_path / "probe.png"
Image.new("RGB", (448, 448), color=(180, 120, 160)).save(img)
models = ["ml_danbooru", "wd_swinv2_v3", "wd_eva02_large"]
for model in models:
try:
scores = extract_scores(img, tagger_model=model, wd_general_threshold=0.35)
except Exception as err: # pragma: no cover - environment-specific
pytest.skip(f"tagger {model} unavailable: {err}")
assert isinstance(scores, dict)
assert len(scores) > 0, f"{model} returned empty scores"
assert all(0.0 <= float(v) <= 1.0 for v in scores.values())