thr3shr / backend /scripts /debug_realism_probe.py
Dinamush
chore: remove personal paths and album names from tooling
fdb87aa
Raw
History Blame Contribute Delete
32 kB
#!/usr/bin/env python3
"""Empirical probe: can WD taggers separate real photos from anime/illustration?
Does NOT modify taxonomy.json or production routing. Writes a JSON report under
scripts/out/realism_probe_report.json.
Usage (from backend/):
../.venv/Scripts/python.exe scripts/debug_realism_probe.py
../.venv/Scripts/python.exe scripts/debug_realism_probe.py --photos-dir PATH --anime-dir PATH
../.venv/Scripts/python.exe scripts/debug_realism_probe.py --include-ml --limit 12 --threshold 0.15
"""
from __future__ import annotations
import argparse
import json
import os
import sqlite3
import statistics
import sys
import urllib.request
from collections import defaultdict
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import numpy as np
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from app.inference_engine import ( # noqa: E402
TAGGER_MODEL_ML,
TAGGER_MODEL_WD_EVA02,
TAGGER_MODEL_WD_SWINV2,
WD_MODEL_NAMES,
InferenceEngine,
_normalize_scores,
_normalize_tag,
preprocess_mldanbooru,
preprocess_wd14,
reset_engine,
)
IMAGE_EXTS = {".jpg", ".jpeg", ".jfif", ".png", ".bmp", ".webp", ".tiff"}
CANDIDATE_TAGS = (
# Planned taxonomy evidence (may be absent from WD v3 vocab).
"realistic",
"photorealistic",
"photo_(medium)",
"3d",
# WD-v3-adjacent tags actually present in selected_tags.csv.
"photo_(object)",
"photo_background",
"cellphone_photo",
"3d_background",
"holding_photo",
"semi-realistic",
"semi_realistic",
"realistic_proportions",
"depth_of_field",
"blurry",
"bokeh",
"film_grain",
"scan",
"cover_page",
"traditional_media",
"painting_(medium)",
"colored_pencil_(medium)",
"graphite_(medium)",
)
# Tags used for precision/recall sweeps + combo policy.
PRIMARY_EVIDENCE_TAGS = (
"realistic",
"photorealistic",
"photo_(medium)",
"3d",
"photo_background",
"cellphone_photo",
"3d_background",
)
# Optional local dirs via env (colon/semicolon-separated). Empty = Wikimedia fallbacks.
def _env_dirs(name: str) -> list[Path]:
raw = os.environ.get(name, "").strip()
if not raw:
return []
sep = ";" if ";" in raw else os.pathsep
return [Path(p.strip()) for p in raw.split(sep) if p.strip()]
DEFAULT_PHOTO_DIRS = _env_dirs("THR3SHR_PROBE_PHOTO_DIRS")
DEFAULT_ANIME_DIRS = _env_dirs("THR3SHR_PROBE_ANIME_DIRS") + [
Path(__file__).resolve().parents[2] / "sample_data" / "sfw_safebooru",
]
WIKIMEDIA_FALLBACK_PHOTOS = [
# Clearly photographic public-domain / CC-ish Wikimedia files (thumbnails).
(
"wm_eiffel.jpg",
"https://upload.wikimedia.org/wikipedia/commons/thumb/a/a8/Tour_Eiffel_Wikimedia_Commons.jpg/640px-Tour_Eiffel_Wikimedia_Commons.jpg",
),
(
"wm_sunset.jpg",
"https://upload.wikimedia.org/wikipedia/commons/thumb/5/58/Sunset_2007.jpg/640px-Sunset_2007.jpg",
),
(
"wm_cat.jpg",
"https://upload.wikimedia.org/wikipedia/commons/thumb/3/3a/Cat03.jpg/640px-Cat03.jpg",
),
(
"wm_dog.jpg",
"https://upload.wikimedia.org/wikipedia/commons/thumb/2/26/YellowLabradorLooking_new.jpg/640px-YellowLabradorLooking_new.jpg",
),
(
"wm_flower.jpg",
"https://upload.wikimedia.org/wikipedia/commons/thumb/4/40/Sunflower_sky_backdrop.jpg/640px-Sunflower_sky_backdrop.jpg",
),
(
"wm_bridge.jpg",
"https://upload.wikimedia.org/wikipedia/commons/thumb/d/dd/GoldenGateBridge-001.jpg/640px-GoldenGateBridge-001.jpg",
),
(
"wm_mountain.jpg",
"https://upload.wikimedia.org/wikipedia/commons/thumb/e/e7/Everest_North_Face_toward_Base_Camp_Tibet_Luca_Galuzzi_2006.jpg/640px-Everest_North_Face_toward_Base_Camp_Tibet_Luca_Galuzzi_2006.jpg",
),
(
"wm_city.jpg",
"https://upload.wikimedia.org/wikipedia/commons/thumb/4/47/New_york_times_square-terabass.jpg/640px-New_york_times_square-terabass.jpg",
),
]
def _list_images(directory: Path, *, recursive: bool = True) -> list[Path]:
if not directory.exists() or not directory.is_dir():
return []
iterator = directory.rglob("*") if recursive else directory.iterdir()
out: list[Path] = []
for path in iterator:
if path.is_file() and path.suffix.lower() in IMAGE_EXTS:
out.append(path)
return sorted(out)
def _take_diverse(paths: list[Path], limit: int) -> list[Path]:
if limit <= 0 or len(paths) <= limit:
return list(paths)
# Prefer spreading across subdirs.
by_parent: dict[str, list[Path]] = defaultdict(list)
for path in paths:
by_parent[str(path.parent)].append(path)
selected: list[Path] = []
buckets = [list(v) for v in by_parent.values()]
while len(selected) < limit and buckets:
next_buckets: list[list[Path]] = []
for bucket in buckets:
if not bucket:
continue
selected.append(bucket.pop(0))
if len(selected) >= limit:
break
if bucket:
next_buckets.append(bucket)
buckets = next_buckets
return selected[:limit]
def _download(url: str, dest: Path) -> None:
request = urllib.request.Request(
url,
headers={"User-Agent": "thr3shr-realism-probe/1.0"},
)
with urllib.request.urlopen(request, timeout=90) as resp:
dest.write_bytes(resp.read())
def ensure_fallback_photos(dest_dir: Path, needed: int) -> list[Path]:
dest_dir.mkdir(parents=True, exist_ok=True)
paths: list[Path] = []
for name, url in WIKIMEDIA_FALLBACK_PHOTOS:
if len(paths) >= needed:
break
dest = dest_dir / name
if not dest.exists():
print(f"downloading fallback photo {name} …", flush=True)
try:
_download(url, dest)
except Exception as exc: # noqa: BLE001
print(f" failed: {exc}", flush=True)
continue
if dest.exists() and dest.stat().st_size > 0:
paths.append(dest)
return paths
def discover_from_db(limit: int = 20) -> list[Path]:
db = ROOT / "app.db"
if not db.exists():
return []
try:
conn = sqlite3.connect(db)
rows = conn.execute(
"SELECT file_path FROM items ORDER BY id DESC LIMIT ?",
(limit * 3,),
).fetchall()
except Exception: # noqa: BLE001
return []
found: list[Path] = []
for (raw,) in rows:
path = Path(str(raw))
if path.is_file() and path.suffix.lower() in IMAGE_EXTS:
found.append(path)
if len(found) >= limit:
break
return found
def collect_labeled_sets(
*,
photos_dir: Path | None,
anime_dir: Path | None,
limit: int,
allow_download: bool,
) -> tuple[list[dict[str, Any]], list[str]]:
notes: list[str] = []
samples: list[dict[str, Any]] = []
photo_paths: list[Path] = []
if photos_dir:
photo_paths = _list_images(photos_dir)
notes.append(f"photos-dir={photos_dir} count={len(photo_paths)}")
else:
for d in DEFAULT_PHOTO_DIRS:
found = _list_images(d)
if found:
photo_paths.extend(found)
notes.append(f"auto photo dir {d} (+{len(found)})")
# Optional: scan Pictures for WhatsApp / camera dumps when enabled
if os.environ.get("THR3SHR_PROBE_SCAN_PICTURES", "").strip().lower() in {
"1",
"true",
"yes",
"on",
}:
pics_root = Path.home() / "Pictures"
if pics_root.exists():
extras = []
for p in pics_root.iterdir():
if not p.is_file() or p.suffix.lower() not in IMAGE_EXTS:
continue
name = p.name.lower()
if "whatsapp" in name or name.startswith("img_"):
extras.append(p)
if extras:
photo_paths.extend(extras)
notes.append(f"auto Pictures root camera-like (+{len(extras)})")
anime_paths: list[Path] = []
if anime_dir:
anime_paths = _list_images(anime_dir)
notes.append(f"anime-dir={anime_dir} count={len(anime_paths)}")
else:
# Pull a slice from several anime dirs so semi-real / style diversity appears.
per_dir = max(limit, 8)
for d in DEFAULT_ANIME_DIRS:
found = _list_images(d)
if not found:
continue
slice_n = min(len(found), per_dir)
anime_paths.extend(_take_diverse(found, slice_n))
notes.append(f"auto anime dir {d} (+{slice_n}/{len(found)})")
if len(anime_paths) >= limit * 5:
break
if len(photo_paths) < max(4, limit // 2) and allow_download:
needed = max(limit, 8) - len(photo_paths)
fallback = ensure_fallback_photos(
ROOT / "scripts" / "out" / "realism_probe_tmp_photos",
needed=max(needed, 8),
)
photo_paths.extend(fallback)
notes.append(f"wikimedia fallback photos (+{len(fallback)})")
if not anime_paths:
db_paths = discover_from_db(limit * 2)
anime_paths.extend(db_paths)
notes.append(f"db recent items as anime-pool (+{len(db_paths)})")
photo_sel = _take_diverse(photo_paths, limit)
anime_sel = _take_diverse(anime_paths, limit)
if len(photo_sel) < 8:
notes.append(f"SHORTFALL photos: got {len(photo_sel)} (aim ≥8)")
if len(anime_sel) < 8:
notes.append(f"SHORTFALL anime: got {len(anime_sel)} (aim ≥8)")
for path in photo_sel:
samples.append({"path": str(path), "label": "photo", "source": "photos"})
for path in anime_sel:
samples.append({"path": str(path), "label": "anime", "source": "anime"})
return samples, notes
def score_wd_raw(
engine: InferenceEngine,
images: list[Path],
*,
tagger_model: str,
batch_size: int = 4,
) -> list[dict[str, Any]]:
"""Return full general + rating scores (no threshold filter)."""
wd_name = WD_MODEL_NAMES[tagger_model]
session = engine._get_session(tagger_model)
target = engine._wd_target_size[wd_name]
tensors = [preprocess_wd14(image, target) for image in images]
preds_list: list[np.ndarray] = []
input_name = session.get_inputs()[0].name
out0 = session.get_outputs()[0].name
out1 = session.get_outputs()[1].name
for start in range(0, len(tensors), batch_size):
chunk = tensors[start : start + batch_size]
feed = chunk[0] if len(chunk) == 1 else np.concatenate(chunk, axis=0)
with engine._run_lock:
preds, _emb = session.run([out0, out1], {input_name: feed})
for row in preds:
preds_list.append(row)
tag_names, rating_idx, general_idx, _char_idx = engine._get_wd_labels(wd_name)
results: list[dict[str, Any]] = []
for pred in preds_list:
labels = list(zip(tag_names, pred.astype(float)))
general = {
name: float(score)
for i in general_idx
for name, score in [labels[i]]
}
rating = {
name: float(score)
for i in rating_idx
for name, score in [labels[i]]
}
general_n = _normalize_scores(general)
rating_n = _normalize_scores(rating)
results.append(
{
"general": general_n,
"rating": rating_n,
"all": {**general_n, **{f"rating:{k}": v for k, v in rating_n.items()}},
}
)
return results
def score_ml_raw(engine: InferenceEngine, images: list[Path]) -> list[dict[str, Any]]:
results: list[dict[str, Any]] = []
for image in images:
tensor = preprocess_mldanbooru(image, size=448, keep_ratio=True)
session = engine._get_session(TAGGER_MODEL_ML)
with engine._run_lock:
(native_output,) = session.run(["output"], {"input": tensor})
probs = 1.0 / (1.0 + np.exp(-native_output.reshape(-1)))
labels = engine._get_ml_labels()
scores = {
labels[i]: float(probs[i])
for i in range(min(len(labels), len(probs)))
}
scores_n = _normalize_scores(scores)
results.append({"general": scores_n, "rating": {}, "all": scores_n})
return results
def top_n(scores: dict[str, float], n: int = 15) -> list[dict[str, float | str]]:
items = sorted(scores.items(), key=lambda kv: (-kv[1], kv[0]))[:n]
return [{"tag": t, "score": round(float(s), 6)} for t, s in items]
def candidate_scores(scores: dict[str, float]) -> dict[str, float]:
out: dict[str, float] = {}
for tag in CANDIDATE_TAGS:
ntag = _normalize_tag(tag)
best = float(scores.get(ntag, 0.0) or 0.0)
# Accept raw/unnormalized keys if present.
if tag in scores:
best = max(best, float(scores[tag]))
out[tag] = round(best, 6)
return out
def separation_metrics(
rows: list[dict[str, Any]],
*,
model: str,
tag: str,
thresholds: list[float],
) -> list[dict[str, Any]]:
photos = [r for r in rows if r["label"] == "photo"]
anime = [r for r in rows if r["label"] == "anime"]
out: list[dict[str, Any]] = []
for thr in thresholds:
tp = sum(1 for r in photos if r["models"][model]["candidates"].get(tag, 0) >= thr)
fn = len(photos) - tp
fp = sum(1 for r in anime if r["models"][model]["candidates"].get(tag, 0) >= thr)
tn = len(anime) - fp
prec = tp / (tp + fp) if (tp + fp) else 0.0
rec = tp / (tp + fn) if (tp + fn) else 0.0
f1 = (2 * prec * rec / (prec + rec)) if (prec + rec) else 0.0
# Treat "predict photo" as positive class.
out.append(
{
"tag": tag,
"threshold": thr,
"tp": tp,
"fp": fp,
"tn": tn,
"fn": fn,
"precision": round(prec, 4),
"recall": round(rec, 4),
"f1": round(f1, 4),
"anime_false_positive_rate": round(fp / len(anime), 4) if anime else None,
}
)
return out
def single_tag_policy_metrics(
rows: list[dict[str, Any]],
*,
model: str,
tag: str,
thresholds: list[float],
) -> list[dict[str, Any]]:
"""Convenience wrapper naming single-tag threshold sweeps as a policy."""
rows_out = separation_metrics(rows, model=model, tag=tag, thresholds=thresholds)
for row in rows_out:
row["policy"] = f"single_tag:{tag}"
return rows_out
def combo_metrics(
rows: list[dict[str, Any]],
*,
model: str,
thresholds: list[float],
) -> list[dict[str, Any]]:
"""Hard evidence OR soft corroboration policy matching the planned fixture."""
photos = [r for r in rows if r["label"] == "photo"]
anime = [r for r in rows if r["label"] == "anime"]
out: list[dict[str, Any]] = []
def predicts_photo(cands: dict[str, float], thr: float) -> bool:
hard = max(
cands.get("photorealistic", 0.0),
cands.get("photo_(medium)", 0.0),
cands.get("photo_background", 0.0),
cands.get("cellphone_photo", 0.0),
)
if hard >= thr:
return True
soft_tags = ("realistic", "3d", "3d_background")
soft_hits = [cands.get(t, 0.0) for t in soft_tags if cands.get(t, 0.0) >= thr]
# Corroboration: ≥2 soft tags above thr, or one soft + any other realism ≥0.15
if len(soft_hits) >= 2:
return True
if soft_hits:
others = [
cands.get(t, 0.0)
for t in (
"photorealistic",
"photo_(medium)",
"photo_background",
"cellphone_photo",
"realistic",
"3d",
"3d_background",
)
if cands.get(t, 0.0) >= 0.15
]
if len(others) >= 2:
return True
return False
for thr in thresholds:
tp = sum(1 for r in photos if predicts_photo(r["models"][model]["candidates"], thr))
fn = len(photos) - tp
fp = sum(1 for r in anime if predicts_photo(r["models"][model]["candidates"], thr))
tn = len(anime) - fp
prec = tp / (tp + fp) if (tp + fp) else 0.0
rec = tp / (tp + fn) if (tp + fn) else 0.0
f1 = (2 * prec * rec / (prec + rec)) if (prec + rec) else 0.0
out.append(
{
"policy": "hard_or_soft_corroborated",
"threshold": thr,
"tp": tp,
"fp": fp,
"tn": tn,
"fn": fn,
"precision": round(prec, 4),
"recall": round(rec, 4),
"f1": round(f1, 4),
"anime_false_positive_rate": round(fp / len(anime), 4) if anime else None,
}
)
return out
def summarize_tag_distributions(
rows: list[dict[str, Any]], model: str
) -> dict[str, Any]:
dist: dict[str, Any] = {}
for tag in PRIMARY_EVIDENCE_TAGS:
for label in ("photo", "anime"):
vals = [
float(r["models"][model]["candidates"].get(tag, 0.0))
for r in rows
if r["label"] == label
]
key = f"{label}:{tag}"
if not vals:
dist[key] = None
continue
dist[key] = {
"n": len(vals),
"mean": round(statistics.mean(vals), 4),
"median": round(statistics.median(vals), 4),
"min": round(min(vals), 4),
"max": round(max(vals), 4),
"p90": round(sorted(vals)[max(0, int(0.9 * (len(vals) - 1)))], 4),
}
return dist
def best_thresholds(
metric_rows: list[dict[str, Any]],
*,
min_precision: float = 0.85,
) -> dict[str, Any]:
"""Prefer high precision (avoid anime FP), then F1, then recall."""
by_tag: dict[str, list[dict[str, Any]]] = defaultdict(list)
for row in metric_rows:
by_tag[row["tag"]].append(row)
picks: dict[str, Any] = {}
for tag, rows in by_tag.items():
eligible = [r for r in rows if r["precision"] >= min_precision]
pool = eligible or rows
best = max(pool, key=lambda r: (r["f1"], r["precision"], r["recall"], -r["threshold"]))
# Also note max-F1 unrestricted
max_f1 = max(rows, key=lambda r: (r["f1"], r["precision"], r["recall"]))
picks[tag] = {
"suggested_for_high_precision": best,
"max_f1": max_f1,
}
return picks
def recommend(
analysis: dict[str, Any],
*,
n_photo: int,
n_anime: int,
) -> dict[str, Any]:
"""GO if a model can hit high precision with usable recall on hard tags."""
go_reasons: list[str] = []
nogo_reasons: list[str] = []
if n_photo < 8 or n_anime < 8:
nogo_reasons.append(
f"Sample shortfall (photos={n_photo}, anime={n_anime}); treat results as preliminary."
)
model_rank: list[tuple[str, float, dict[str, Any]]] = []
for model, block in analysis.items():
combo = block.get("combo_policy", [])
realistic_alone = block.get("realistic_alone_policy", [])
hard_picks = block.get("suggested_thresholds", {})
best_combo = max(combo, key=lambda r: (r["f1"], r["precision"], r["recall"])) if combo else None
best_realistic = (
max(realistic_alone, key=lambda r: (r["f1"], r["precision"], r["recall"]))
if realistic_alone
else None
)
photo_pr = block["distributions"].get("photo:photorealistic", {}) or {}
anime_pr = block["distributions"].get("anime:photorealistic", {}) or {}
photo_r = block["distributions"].get("photo:realistic", {}) or {}
anime_r = block["distributions"].get("anime:realistic", {}) or {}
sep_pr = (photo_pr.get("mean", 0) or 0) - (anime_pr.get("mean", 0) or 0)
sep_r = (photo_r.get("mean", 0) or 0) - (anime_r.get("mean", 0) or 0)
# Prefer WD-usable signals; combo may under-recall when photo_(medium)/3d absent.
score = 0.0
if best_realistic:
score = best_realistic["f1"] * 0.55 + best_realistic["precision"] * 0.35
if best_combo:
score = max(score, best_combo["f1"] * 0.55 + best_combo["precision"] * 0.35)
score += max(0.0, sep_r) * 0.1 + max(0.0, sep_pr) * 0.05
model_rank.append(
(
model,
score,
{
"best_combo": best_combo,
"best_realistic_alone": best_realistic,
"hard": hard_picks,
},
)
)
model_rank.sort(key=lambda x: x[1], reverse=True)
best_model = model_rank[0][0] if model_rank else None
best_combo = model_rank[0][2]["best_combo"] if model_rank else None
best_realistic = model_rank[0][2]["best_realistic_alone"] if model_rank else None
decision = "NO-GO"
# WD path: realistic alone is the strongest discriminator on this corpus.
primary = best_realistic if (best_realistic and best_realistic["f1"] >= (best_combo or {}).get("f1", 0)) else best_combo
if primary and primary["precision"] >= 0.9 and primary["recall"] >= 0.5 and primary["f1"] >= 0.6:
decision = "GO"
go_reasons.append(
f"{best_model} policy={primary.get('policy', primary.get('tag'))} "
f"thr={primary['threshold']}: P={primary['precision']} R={primary['recall']} F1={primary['f1']}"
)
elif primary and primary["precision"] >= 0.85 and primary["recall"] >= 0.35:
decision = "CONDITIONAL-GO"
go_reasons.append(
f"{best_model} usable but limited "
f"(P={primary['precision']} R={primary['recall']} @ {primary['threshold']})"
)
else:
nogo_reasons.append(
"No model/threshold achieved high-precision photo detection with acceptable recall."
)
go_reasons.append(
"WD v3 vocab lacks photo_(medium) and plain 3d — planned fixture tags are dead for WD; "
"use photorealistic/realistic (and optionally photo_background)."
)
if best_realistic and best_combo and best_realistic["f1"] > best_combo["f1"]:
go_reasons.append(
"Fixture-style hard/soft corroboration under-recalls on WD because soft realistic "
"alone is discarded; consider treating high realistic as hard for WD, or lower "
"soft_alone_weight."
)
failure_modes = [
"WD selected_tags lack photo_(medium) and 3d — those evidence rows never fire on WD.",
"Non-person photos (moon/sky/landscape) often under-score photorealistic; realistic still helps on EVA02.",
"AI-art / photoreal anime may raise photorealistic/realistic (not stressed in this anime sample).",
"ml_danbooru fires photo_(medium)/3d but has higher anime false-positive risk below ~0.35.",
"3d / CGI game renders: WD has only 3d_background (weak here); ml_danbooru 3d is broader.",
"Semi-realistic anime: treat realistic as soft unless threshold is high or corroborated.",
"Topaz/AI-enhanced portraits can suppress WD realism tags (see IMG_1796 miss on SwinV2).",
]
return {
"decision": decision,
"best_model": best_model,
"model_ranking": [
{
"model": m,
"score": round(s, 4),
"best_combo": info["best_combo"],
"best_realistic_alone": info["best_realistic_alone"],
}
for m, s, info in model_rank
],
"go_reasons": go_reasons,
"nogo_reasons": nogo_reasons,
"failure_modes": failure_modes,
"suggested_bucket_name": "real_life",
"suggested_thresholds_by_model": {
m: {
"realistic": (info["best_realistic_alone"] or {}).get("threshold"),
"combo": (info["best_combo"] or {}).get("threshold"),
"photorealistic_hip": (
(info["hard"].get("photorealistic") or {})
.get("suggested_for_high_precision", {})
.get("threshold")
),
}
for m, _s, info in model_rank
},
}
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--photos-dir", type=Path, default=None)
p.add_argument("--anime-dir", type=Path, default=None)
p.add_argument("--limit", type=int, default=12, help="Max images per class")
p.add_argument(
"--threshold",
type=float,
default=0.15,
help="general_threshold used when reporting filtered top tags (raw always kept)",
)
p.add_argument("--include-ml", action="store_true", help="Also score ml_danbooru")
p.add_argument("--no-download", action="store_true", help="Do not fetch Wikimedia fallbacks")
p.add_argument(
"--out",
type=Path,
default=ROOT / "scripts" / "out" / "realism_probe_report.json",
)
p.add_argument("--batch-size", type=int, default=4)
return p.parse_args()
def main() -> int:
args = parse_args()
samples, notes = collect_labeled_sets(
photos_dir=args.photos_dir,
anime_dir=args.anime_dir,
limit=args.limit,
allow_download=not args.no_download,
)
n_photo = sum(1 for s in samples if s["label"] == "photo")
n_anime = sum(1 for s in samples if s["label"] == "anime")
print(f"samples photos={n_photo} anime={n_anime}", flush=True)
for note in notes:
print(f" note: {note}", flush=True)
if not samples:
print("No samples found.", flush=True)
return 1
models = [TAGGER_MODEL_WD_SWINV2, TAGGER_MODEL_WD_EVA02]
if args.include_ml:
models.append(TAGGER_MODEL_ML)
reset_engine()
engine = InferenceEngine()
paths = [Path(s["path"]) for s in samples]
# Warm + raw score per model
per_model_raw: dict[str, list[dict[str, Any]]] = {}
for model in models:
print(f"scoring {model} …", flush=True)
engine.warm(model)
if model == TAGGER_MODEL_ML:
per_model_raw[model] = score_ml_raw(engine, paths)
else:
per_model_raw[model] = score_wd_raw(
engine,
paths,
tagger_model=model,
batch_size=max(1, args.batch_size),
)
rows: list[dict[str, Any]] = []
for i, sample in enumerate(samples):
entry: dict[str, Any] = {
"path": sample["path"],
"label": sample["label"],
"source": sample["source"],
"models": {},
}
for model in models:
raw = per_model_raw[model][i]
general = raw["general"]
cands = candidate_scores(general)
filtered = {
k: float(v)
for k, v in general.items()
if float(v) > args.threshold
}
entry["models"][model] = {
"candidates": cands,
"rating": {k: round(float(v), 6) for k, v in raw["rating"].items()},
"top_tags": top_n(filtered if filtered else general, n=15),
"top_tags_raw_unfiltered": top_n(general, n=10),
}
rows.append(entry)
thresholds = [0.10, 0.15, 0.20, 0.25, 0.30, 0.35, 0.40, 0.50, 0.60, 0.70]
analysis: dict[str, Any] = {}
# Vocab presence check (WD models omit photo_(medium)/3d in v3 selected_tags).
vocab_notes: list[str] = []
for model in models:
if model == TAGGER_MODEL_ML:
labels = engine._get_ml_labels()
present = sorted({t for t in PRIMARY_EVIDENCE_TAGS if _normalize_tag(t) in {_normalize_tag(x) for x in labels} or t in labels})
missing = [t for t in PRIMARY_EVIDENCE_TAGS if t not in present and _normalize_tag(t) not in {_normalize_tag(x) for x in labels}]
else:
wd_name = WD_MODEL_NAMES[model]
names, _, _, _ = engine._get_wd_labels(wd_name)
name_set = set(names)
norm_map = {_normalize_tag(n): n for n in names}
present = []
missing = []
for t in PRIMARY_EVIDENCE_TAGS:
if t in name_set or _normalize_tag(t) in norm_map:
present.append(t)
else:
missing.append(t)
vocab_notes.append(f"{model} evidence present={present} missing={missing}")
notes.append(vocab_notes[-1])
for model in models:
tag_metrics: list[dict[str, Any]] = []
for tag in PRIMARY_EVIDENCE_TAGS:
tag_metrics.extend(
separation_metrics(rows, model=model, tag=tag, thresholds=thresholds)
)
combo = combo_metrics(rows, model=model, thresholds=thresholds)
realistic_alone = single_tag_policy_metrics(
rows, model=model, tag="realistic", thresholds=thresholds
)
analysis[model] = {
"distributions": summarize_tag_distributions(rows, model),
"per_tag_metrics": tag_metrics,
"combo_policy": combo,
"realistic_alone_policy": realistic_alone,
"suggested_thresholds": best_thresholds(tag_metrics, min_precision=0.85),
}
rec = recommend(analysis, n_photo=n_photo, n_anime=n_anime)
report = {
"generated_at": datetime.now(timezone.utc).isoformat(),
"general_threshold_for_top_tags": args.threshold,
"models": models,
"candidate_tags": list(CANDIDATE_TAGS),
"primary_evidence_tags": list(PRIMARY_EVIDENCE_TAGS),
"vocab_notes": vocab_notes,
"notes": notes,
"counts": {"photos": n_photo, "anime": n_anime, "total": len(rows)},
"recommendation": rec,
"analysis": analysis,
"images": rows,
}
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(json.dumps(report, indent=2), encoding="utf-8")
print(f"\nWrote {args.out}", flush=True)
print(f"Decision: {rec['decision']} best_model={rec['best_model']}", flush=True)
for line in rec["go_reasons"] + rec["nogo_reasons"]:
print(f" - {line}", flush=True)
# Compact console table
for model in models:
print(f"\n=== {model} distributions (mean) ===")
dist = analysis[model]["distributions"]
for tag in PRIMARY_EVIDENCE_TAGS:
p = dist.get(f"photo:{tag}") or {}
a = dist.get(f"anime:{tag}") or {}
print(
f" {tag:18} photo_mean={p.get('mean')} anime_mean={a.get('mean')} "
f"photo_max={p.get('max')} anime_max={a.get('max')}"
)
print(" best combo rows:")
for row in analysis[model]["combo_policy"]:
if row["threshold"] in {0.15, 0.25, 0.35, 0.5}:
print(
f" thr={row['threshold']}: P={row['precision']} R={row['recall']} "
f"F1={row['f1']} fp_rate={row['anime_false_positive_rate']}"
)
print(" realistic-alone rows:")
for row in analysis[model]["realistic_alone_policy"]:
if row["threshold"] in {0.15, 0.25, 0.35, 0.5}:
print(
f" thr={row['threshold']}: P={row['precision']} R={row['recall']} "
f"F1={row['f1']} fp_rate={row['anime_false_positive_rate']}"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())