from __future__ import annotations from dataclasses import asdict, dataclass import json import math from pathlib import Path from typing import Callable, Sequence EVE_MODEL_ID = "facebook/dinov2-small" @dataclass(slots=True) class EveResult: path: str match_score: float decision_confidence: float suggestion: str def _normalize(vector: Sequence[float]) -> list[float]: length = math.sqrt(sum(float(value) ** 2 for value in vector)) or 1.0 return [float(value) / length for value in vector] def _centroid(vectors: Sequence[Sequence[float]]) -> list[float]: if not vectors: raise ValueError("EVE needs at least one good reference image.") width = len(vectors[0]) if not width or any(len(vector) != width for vector in vectors): raise ValueError("EVE received incompatible image embeddings.") return _normalize([ sum(float(vector[index]) for vector in vectors) / len(vectors) for index in range(width) ]) def _cosine(left: Sequence[float], right: Sequence[float]) -> float: return sum(a * b for a, b in zip(_normalize(left), _normalize(right))) def classify_eve_embeddings( image_paths: Sequence[str | Path], image_vectors: Sequence[Sequence[float]], good_vectors: Sequence[Sequence[float]], bad_vectors: Sequence[Sequence[float]] = (), *, keep_threshold: float = 0.75, reject_threshold: float = 0.25, ) -> list[EveResult]: """Classify embeddings while keeping the uncertain middle reviewable.""" if len(image_paths) != len(image_vectors): raise ValueError("EVE needs one embedding for every dataset image.") if not 0 <= reject_threshold < keep_threshold <= 1: raise ValueError("EVE thresholds must leave an uncertain middle range.") good_center = _centroid(good_vectors) bad_center = _centroid(bad_vectors) if bad_vectors else None # With only good references, calibrate the decision boundary from how # tightly the references agree with their own centroid. reference_floor = min(_cosine(vector, good_center) for vector in good_vectors) one_class_center = max(0.20, reference_floor - 0.18) results: list[EveResult] = [] for raw_path, vector in zip(image_paths, image_vectors): positive = _cosine(vector, good_center) if bad_center is not None: negative = _cosine(vector, bad_center) # Temperature-scaled two-prototype probability. delta = max(-30.0, min(30.0, (positive - negative) / 0.08)) score = 1.0 / (1.0 + math.exp(-delta)) else: delta = max(-30.0, min(30.0, (positive - one_class_center) / 0.08)) score = 1.0 / (1.0 + math.exp(-delta)) if score >= keep_threshold: suggestion = "keep" confidence = score elif score <= reject_threshold: suggestion = "reject" confidence = 1.0 - score else: suggestion = "unreviewed" confidence = max(score, 1.0 - score) results.append(EveResult(str(Path(raw_path).resolve()), score, confidence, suggestion)) return results class EveVisionModel: """Lazy local DINOv2 feature extractor used by EVE.""" def __init__(self, model_id: str = EVE_MODEL_ID) -> None: self.model_id = model_id self._processor = None self._model = None self._device = "cpu" def load(self) -> None: if self._model is not None: return try: import os os.environ.setdefault("USE_TF", "0") os.environ.setdefault("USE_FLAX", "0") import torch from transformers import AutoImageProcessor, AutoModel except ImportError as exc: raise RuntimeError( "EVE needs PyTorch and Transformers. Launch ADAM with its normal Python environment." ) from exc self._device = "cuda" if torch.cuda.is_available() else "cpu" self._processor = AutoImageProcessor.from_pretrained(self.model_id, use_fast=True) self._model = AutoModel.from_pretrained(self.model_id).to(self._device).eval() def embed(self, paths: Sequence[str | Path], progress: Callable[[int, int], None] | None = None) -> list[list[float]]: self.load() import torch from PIL import Image vectors: list[list[float]] = [] total = len(paths) for index, path in enumerate(paths, 1): try: with Image.open(path) as source: image = source.convert("RGB") inputs = self._processor(images=image, return_tensors="pt") inputs = {key: value.to(self._device) for key, value in inputs.items()} with torch.inference_mode(): output = self._model(**inputs).last_hidden_state[:, 0] vector = torch.nn.functional.normalize(output, dim=-1)[0].cpu().tolist() except Exception as exc: raise RuntimeError(f"EVE could not analyze {Path(path).name}: {exc}") from exc vectors.append(vector) if progress: progress(index, total) return vectors def review( self, dataset_paths: Sequence[str | Path], good_references: Sequence[str | Path], bad_references: Sequence[str | Path] = (), *, keep_threshold: float = 0.75, reject_threshold: float = 0.25, progress: Callable[[int, int], None] | None = None, ) -> list[EveResult]: references = [*good_references, *bad_references] reference_vectors = self.embed(references) image_vectors = self.embed(dataset_paths, progress) split = len(good_references) return classify_eve_embeddings( dataset_paths, image_vectors, reference_vectors[:split], reference_vectors[split:], keep_threshold=keep_threshold, reject_threshold=reject_threshold, ) def save_eve_results(root: Path, dataset_path: str, results: Sequence[EveResult]) -> Path: """Persist the latest EVE proposal so it can be audited after application.""" import hashlib key = hashlib.sha1(str(Path(dataset_path).resolve()).encode("utf-8")).hexdigest()[:12] destination = root.resolve() / "data" / "eve_reviews" / f"{key}.json" destination.parent.mkdir(parents=True, exist_ok=True) temporary = destination.with_suffix(".tmp") temporary.write_text(json.dumps({ "agent": "EVE", "dataset_path": str(Path(dataset_path).resolve()), "results": [asdict(result) for result in results], }, indent=2), encoding="utf-8") temporary.replace(destination) return destination