| |
| """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 ( |
| 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 = ( |
| |
| "realistic", |
| "photorealistic", |
| "photo_(medium)", |
| "3d", |
| |
| "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)", |
| ) |
|
|
| |
| PRIMARY_EVIDENCE_TAGS = ( |
| "realistic", |
| "photorealistic", |
| "photo_(medium)", |
| "3d", |
| "photo_background", |
| "cellphone_photo", |
| "3d_background", |
| ) |
|
|
| |
| 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 = [ |
| |
| ( |
| "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) |
| |
| 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: |
| 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: |
| 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)})") |
| |
| 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: |
| |
| 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) |
| |
| 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 |
| |
| 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] |
| |
| 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"])) |
| |
| 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) |
| |
| 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" |
| |
| 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] |
|
|
| |
| 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_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) |
|
|
| |
| 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()) |
|
|