Upload 29 files
Browse files- raven/__init__.py +1 -0
- raven/anchors.py +45 -0
- raven/audit.py +56 -0
- raven/benchmark.py +43 -0
- raven/budget.py +176 -0
- raven/cache.py +323 -0
- raven/calibration.py +81 -0
- raven/cli.py +87 -0
- raven/config.py +12 -0
- raven/dataset.py +104 -0
- raven/diagnose.py +39 -0
- raven/directml_inference.py +36 -0
- raven/evaluate.py +30 -0
- raven/forensic_features.py +210 -0
- raven/foundation.py +56 -0
- raven/foundation_runtime.py +179 -0
- raven/hard_mining.py +14 -0
- raven/inference.py +61 -0
- raven/manifest.py +176 -0
- raven/metrics.py +58 -0
- raven/model.py +65 -0
- raven/onnx_export.py +39 -0
- raven/onnx_runtime.py +39 -0
- raven/parquet_io.py +237 -0
- raven/provenance.py +18 -0
- raven/specialists.py +264 -0
- raven/training.py +200 -0
- raven/transforms.py +67 -0
- raven/utils.py +67 -0
raven/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
__version__ = "5.0.0"
|
raven/anchors.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
import numpy as np
|
| 3 |
+
from sklearn.preprocessing import StandardScaler
|
| 4 |
+
from sklearn.decomposition import PCA
|
| 5 |
+
from sklearn.covariance import LedoitWolf
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class RobustAnchor:
|
| 9 |
+
def __init__(self, scaler_mean, scaler_scale, pca_mean, pca_components, location, precision, dist_mean, dist_std):
|
| 10 |
+
self.scaler_mean=np.asarray(scaler_mean,np.float64); self.scaler_scale=np.asarray(scaler_scale,np.float64)
|
| 11 |
+
self.pca_mean=np.asarray(pca_mean,np.float64); self.pca_components=np.asarray(pca_components,np.float64)
|
| 12 |
+
self.location=np.asarray(location,np.float64); self.precision=np.asarray(precision,np.float64); self.dist_mean=float(dist_mean); self.dist_std=float(dist_std)
|
| 13 |
+
@classmethod
|
| 14 |
+
def fit(cls,x:np.ndarray,max_components:int=48):
|
| 15 |
+
x=np.asarray(x,np.float64); sc=StandardScaler().fit(x); xs=sc.transform(x)
|
| 16 |
+
ncomp=max(1,min(int(max_components),xs.shape[1],max(1,xs.shape[0]-2)))
|
| 17 |
+
pca=PCA(n_components=ncomp,whiten=False,svd_solver="auto",random_state=1337).fit(xs); xp=pca.transform(xs)
|
| 18 |
+
lw=LedoitWolf(assume_centered=False).fit(xp); d=cls._distance_raw(xp,lw.location_,lw.precision_); dm=float(d.mean()); ds=float(d.std()+1e-6)
|
| 19 |
+
return cls(sc.mean_,sc.scale_+1e-8,pca.mean_,pca.components_,lw.location_,lw.precision_,dm,ds)
|
| 20 |
+
@staticmethod
|
| 21 |
+
def _distance_raw(x,loc,precision):
|
| 22 |
+
q=x-loc[None,:]; d2=np.einsum("bi,ij,bj->b",q,precision,q); return np.sqrt(np.maximum(d2,0.0))
|
| 23 |
+
def project(self,x):
|
| 24 |
+
xs=(np.asarray(x,np.float64)-self.scaler_mean[None,:])/self.scaler_scale[None,:]; return (xs-self.pca_mean[None,:])@self.pca_components.T
|
| 25 |
+
def score(self,x):
|
| 26 |
+
d=self._distance_raw(self.project(x),self.location,self.precision); return ((d-self.dist_mean)/self.dist_std).astype(np.float32)
|
| 27 |
+
def to_dict(self):
|
| 28 |
+
return {"scaler_mean":self.scaler_mean.astype(np.float32),"scaler_scale":self.scaler_scale.astype(np.float32),"pca_mean":self.pca_mean.astype(np.float32),"pca_components":self.pca_components.astype(np.float32),"location":self.location.astype(np.float32),"precision":self.precision.astype(np.float32),"dist_mean":self.dist_mean,"dist_std":self.dist_std}
|
| 29 |
+
@classmethod
|
| 30 |
+
def from_dict(cls,d): return cls(d["scaler_mean"],d["scaler_scale"],d["pca_mean"],d["pca_components"],d["location"],d["precision"],d["dist_mean"],d["dist_std"])
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class AnchorSet:
|
| 34 |
+
BRANCHES=("semantic","camera","spectral","patch","specialist")
|
| 35 |
+
def __init__(self,anchors:dict[str,RobustAnchor]): self.anchors=anchors
|
| 36 |
+
@classmethod
|
| 37 |
+
def fit(cls,cache:dict,cfg:dict):
|
| 38 |
+
mask=(cache["label"]==0)&(cache["split"]=="train")
|
| 39 |
+
if int(mask.sum())<40: raise ValueError("Need >=40 REAL train feature views to fit anchors")
|
| 40 |
+
nmax=int(cfg["anchors"].get("pca_max_components",48)); return cls({b:RobustAnchor.fit(cache[b][mask],nmax) for b in cls.BRANCHES})
|
| 41 |
+
def score(self,features:dict) -> np.ndarray:
|
| 42 |
+
return np.stack([self.anchors[b].score(features[b]) for b in self.BRANCHES],axis=1).astype(np.float32)
|
| 43 |
+
def to_dict(self): return {b:a.to_dict() for b,a in self.anchors.items()}
|
| 44 |
+
@classmethod
|
| 45 |
+
def from_dict(cls,d): return cls({b:RobustAnchor.from_dict(v) for b,v in d.items()})
|
raven/audit.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
from collections import Counter, defaultdict
|
| 3 |
+
import numpy as np
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def _rate(counter: Counter, n: int) -> dict:
|
| 7 |
+
return {str(k): float(v / max(1, n)) for k, v in counter.items()}
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def audit_manifest(rows: list[dict], cfg: dict | None = None) -> dict:
|
| 11 |
+
report: dict = {"n": len(rows), "warnings": [], "notes": []}
|
| 12 |
+
debias = bool((cfg or {}).get("features", {}).get("resolution_debias", True))
|
| 13 |
+
by_label = defaultdict(list)
|
| 14 |
+
for r in rows:
|
| 15 |
+
by_label[int(r.get("label", -1))].append(r)
|
| 16 |
+
for label, name in ((0, "REAL"), (1, "AI")):
|
| 17 |
+
subset = by_label[label]
|
| 18 |
+
n = len(subset)
|
| 19 |
+
report[name] = {
|
| 20 |
+
"n": n,
|
| 21 |
+
"formats": _rate(Counter(r.get("format", "unknown") for r in subset), n),
|
| 22 |
+
"light": _rate(Counter(r.get("light_bucket", "unknown") for r in subset), n),
|
| 23 |
+
"sources": dict(Counter(str(r.get("source", "unknown")) for r in subset)),
|
| 24 |
+
"raw_sources": dict(Counter(str(r.get("raw_source", r.get("source", "unknown"))) for r in subset)),
|
| 25 |
+
"mean_luma": float(np.nanmean([r.get("mean_luma", np.nan) for r in subset])) if n else None,
|
| 26 |
+
"mean_megapixels": float(np.nanmean([r.get("megapixels", np.nan) for r in subset])) if n else None,
|
| 27 |
+
"exif_rate": float(np.mean([bool(r.get("has_exif", False)) for r in subset])) if n else None,
|
| 28 |
+
}
|
| 29 |
+
if len(by_label[0]) and len(by_label[1]):
|
| 30 |
+
real_fmt = report["REAL"]["formats"]; ai_fmt = report["AI"]["formats"]
|
| 31 |
+
keys = set(real_fmt) | set(ai_fmt)
|
| 32 |
+
fmt_gap = max((abs(real_fmt.get(k, 0.0) - ai_fmt.get(k, 0.0)) for k in keys), default=0.0)
|
| 33 |
+
if fmt_gap > 0.35:
|
| 34 |
+
report["warnings"].append("Large REAL/AI file-format imbalance. Model may learn JPG-vs-PNG shortcuts.")
|
| 35 |
+
rl = report["REAL"]["mean_luma"]; al = report["AI"]["mean_luma"]
|
| 36 |
+
if rl is not None and al is not None and abs(rl - al) > 0.12:
|
| 37 |
+
report["warnings"].append("Large brightness imbalance between REAL and AI. Add matched low-light/bright samples.")
|
| 38 |
+
rr = report["REAL"]["mean_megapixels"]; ar = report["AI"]["mean_megapixels"]
|
| 39 |
+
if rr and ar and max(rr, ar) / max(0.05, min(rr, ar)) > 2.5:
|
| 40 |
+
if debias:
|
| 41 |
+
report["notes"].append("Raw REAL/AI resolutions differ strongly, but v5.1 resolution deconfounding is enabled: all forensic branches use fixed square analysis grids and native megapixels/aspect are excluded from classifier/calibration inputs.")
|
| 42 |
+
else:
|
| 43 |
+
report["warnings"].append("Large resolution imbalance between REAL and AI. Enable features.resolution_debias.")
|
| 44 |
+
nr=len(by_label[0]); na=len(by_label[1])
|
| 45 |
+
report["raw_class_ratio_ai_to_real"] = float(na/max(1,nr)) if nr else None
|
| 46 |
+
report["training_prior_policy"] = "exact 50:50 REAL/AI class-first sampling + equal-class loss + 50:50 bootstrap calibration"
|
| 47 |
+
report["resolution_policy"] = "fixed-grid forensic analysis; native megapixels/aspect excluded" if debias else "raw"
|
| 48 |
+
report["storage_origins"] = dict(Counter(str(r.get("origin_kind","image")) for r in rows))
|
| 49 |
+
if nr and na and max(nr,na)/max(1,min(nr,na)) >= 3:
|
| 50 |
+
report["warnings"].append("Large raw class-count imbalance detected. RAVEN v3 neutralizes it with 50:50 sampling/loss/calibration; report balanced metrics, not raw accuracy.")
|
| 51 |
+
ai_sources = len(report.get("AI", {}).get("sources", {}))
|
| 52 |
+
if ai_sources < 5:
|
| 53 |
+
report["warnings"].append("Fewer than 5 AI generator sources. Unseen-generator generalization will be fragile.")
|
| 54 |
+
if len(by_label[0]) < 700 or len(by_label[1]) < 700:
|
| 55 |
+
report["warnings"].append("Under ~700 images in a class. RAVEN-F2K still runs, but calibration becomes noisy.")
|
| 56 |
+
return report
|
raven/benchmark.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
import copy
|
| 3 |
+
import time
|
| 4 |
+
import torch
|
| 5 |
+
from .foundation import FoundationEncoder
|
| 6 |
+
from .foundation_runtime import FoundationRuntime
|
| 7 |
+
from .utils import configure_cpu
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def benchmark_foundation(cfg: dict, accelerator: str = "auto", loops: int = 5) -> dict:
|
| 11 |
+
cfg = copy.deepcopy(cfg)
|
| 12 |
+
hc = cfg["hardware"]
|
| 13 |
+
fc = cfg["foundation"]
|
| 14 |
+
fc["accelerator"] = accelerator
|
| 15 |
+
configure_cpu(hc.get("torch_threads", 6), hc.get("interop_threads", 1))
|
| 16 |
+
enc = FoundationEncoder(
|
| 17 |
+
fc.get("backend", "dinov2_vits14"),
|
| 18 |
+
bool(fc.get("allow_fallback", True)),
|
| 19 |
+
fc.get("fallback_backend", "torchvision_convnext_tiny"),
|
| 20 |
+
).eval().to("cpu")
|
| 21 |
+
rt = FoundationRuntime(fc, enc)
|
| 22 |
+
b = rt.batch_size
|
| 23 |
+
x = torch.randn(b, 3, 224, 224)
|
| 24 |
+
rt.infer(x)
|
| 25 |
+
loops = max(2, int(loops))
|
| 26 |
+
t0 = time.perf_counter()
|
| 27 |
+
for _ in range(loops):
|
| 28 |
+
rt.infer(x)
|
| 29 |
+
sec = time.perf_counter() - t0
|
| 30 |
+
ips = (b * loops) / max(sec, 1e-9)
|
| 31 |
+
result = {
|
| 32 |
+
"requested_accelerator": accelerator,
|
| 33 |
+
"selected_runtime": rt.backend,
|
| 34 |
+
"foundation_backend": enc.backend,
|
| 35 |
+
"batch_size": b,
|
| 36 |
+
"loops": loops,
|
| 37 |
+
"seconds": sec,
|
| 38 |
+
"foundation_images_per_second": ips,
|
| 39 |
+
"foundation_only_estimate_minutes_30000_images": 30000.0 / max(ips, 1e-9) / 60.0,
|
| 40 |
+
"note": "This measures frozen foundation inference only; JPEG/Parquet decode and forensic branches add extra time.",
|
| 41 |
+
}
|
| 42 |
+
rt.release_accelerator_memory()
|
| 43 |
+
return result
|
raven/budget.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import copy
|
| 4 |
+
import json
|
| 5 |
+
import math
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
import yaml
|
| 9 |
+
|
| 10 |
+
from .benchmark import benchmark_foundation
|
| 11 |
+
from .config import load_config
|
| 12 |
+
from .utils import read_jsonl
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _counts(rows: list[dict]) -> dict:
|
| 16 |
+
out = {"train_real": 0, "train_ai": 0, "val": 0, "test": 0}
|
| 17 |
+
for r in rows:
|
| 18 |
+
split = str(r.get("split", "train"))
|
| 19 |
+
if split == "train":
|
| 20 |
+
out["train_ai" if int(r.get("label", 0)) == 1 else "train_real"] += 1
|
| 21 |
+
elif split == "val":
|
| 22 |
+
out["val"] += 1
|
| 23 |
+
elif split == "test":
|
| 24 |
+
out["test"] += 1
|
| 25 |
+
return out
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _estimate(cfg: dict, counts: dict, ips: float) -> dict:
|
| 29 |
+
fc = cfg["foundation"]
|
| 30 |
+
rc = cfg["real_specialists"]
|
| 31 |
+
tc = cfg["training"]
|
| 32 |
+
cap = int(fc.get("train_cap_per_class", 0))
|
| 33 |
+
tr0 = counts["train_real"] if cap <= 0 else min(counts["train_real"], cap)
|
| 34 |
+
tr1 = counts["train_ai"] if cap <= 0 else min(counts["train_ai"], cap)
|
| 35 |
+
eval_n = counts["val"] + counts["test"]
|
| 36 |
+
base_n = tr0 + tr1 + eval_n
|
| 37 |
+
aug_per_class = int(fc.get("augment_max_per_class", 0))
|
| 38 |
+
aug_n = 0
|
| 39 |
+
if not bool(fc.get("reuse_semantic_for_augmented_views", True)):
|
| 40 |
+
aug_n = min(tr0, aug_per_class) + min(tr1, aug_per_class)
|
| 41 |
+
foundation_n = base_n + aug_n
|
| 42 |
+
foundation_h = foundation_n / max(ips, 1e-6) / 3600.0
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
runtime = str(fc.get("_planned_runtime", "unknown"))
|
| 46 |
+
overhead = {"directml": 1.65, "openvino": 1.80, "torch": 2.15}.get(runtime, 1.90)
|
| 47 |
+
cache_h = foundation_h * overhead
|
| 48 |
+
|
| 49 |
+
specialist_limit = int(rc.get("max_real_images", 0))
|
| 50 |
+
specialist_n = counts["train_real"] if specialist_limit <= 0 else min(counts["train_real"], specialist_limit)
|
| 51 |
+
specialist_epochs = int(rc.get("epochs", 12))
|
| 52 |
+
specialist_h = 0.05 + specialist_n * (0.030 + 0.0015 * specialist_epochs) / 3600.0
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
members = int(tc.get("ensemble_members", 5))
|
| 56 |
+
epochs = int(tc.get("epochs", 60))
|
| 57 |
+
refine = int(tc.get("hard_refine_epochs", 6))
|
| 58 |
+
selected_train = tr0 + tr1
|
| 59 |
+
fusion_h = 0.04 + (selected_train / 10000.0) * members * (epochs + refine) * 0.0018
|
| 60 |
+
calibration_h = 0.08
|
| 61 |
+
total = cache_h + specialist_h + fusion_h + calibration_h
|
| 62 |
+
return {
|
| 63 |
+
"selected_train_real": tr0,
|
| 64 |
+
"selected_train_ai": tr1,
|
| 65 |
+
"eval_images": eval_n,
|
| 66 |
+
"foundation_passes": foundation_n,
|
| 67 |
+
"foundation_hours_raw": foundation_h,
|
| 68 |
+
"cache_hours_est": cache_h,
|
| 69 |
+
"specialist_hours_est": specialist_h,
|
| 70 |
+
"fusion_hours_est": fusion_h,
|
| 71 |
+
"calibration_hours_est": calibration_h,
|
| 72 |
+
"total_hours_est": total,
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def plan_budget(
|
| 77 |
+
manifest: str,
|
| 78 |
+
base_config: str,
|
| 79 |
+
out_config: str,
|
| 80 |
+
target_hours: float = 4.5,
|
| 81 |
+
min_hours: float = 3.0,
|
| 82 |
+
max_hours: float = 6.0,
|
| 83 |
+
accelerator: str = "auto",
|
| 84 |
+
loops: int = 4,
|
| 85 |
+
) -> dict:
|
| 86 |
+
rows = read_jsonl(manifest)
|
| 87 |
+
counts = _counts(rows)
|
| 88 |
+
cfg = copy.deepcopy(load_config(base_config))
|
| 89 |
+
bench = benchmark_foundation(cfg, accelerator, loops)
|
| 90 |
+
ips = float(bench["foundation_images_per_second"])
|
| 91 |
+
runtime = str(bench["selected_runtime"])
|
| 92 |
+
fc, rc, tc = cfg["foundation"], cfg["real_specialists"], cfg["training"]
|
| 93 |
+
fc["_planned_runtime"] = runtime
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
tc["ensemble_members"] = 5
|
| 97 |
+
tc["epochs"] = 60
|
| 98 |
+
tc["early_stop_patience"] = 7
|
| 99 |
+
tc["hard_refine_epochs"] = 6
|
| 100 |
+
tc["hard_refine_lr_multiplier"] = 0.22
|
| 101 |
+
tc["hard_replay_boost"] = 4.0
|
| 102 |
+
tc["contrastive_weight"] = 0.028
|
| 103 |
+
tc["branch_dropout"] = 0.12
|
| 104 |
+
tc["source_dropout_per_member"] = 1
|
| 105 |
+
rc["epochs"] = 12
|
| 106 |
+
rc["early_stop_patience"] = 3
|
| 107 |
+
|
| 108 |
+
rc["max_real_images"] = int(counts["train_real"])
|
| 109 |
+
fc["reuse_semantic_for_augmented_views"] = True
|
| 110 |
+
fc["augment_max_per_class"] = 6000
|
| 111 |
+
fc["train_coreset"] = True
|
| 112 |
+
fc["train_cap_per_class"] = 0
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
target = min(max(float(target_hours), float(min_hours)), float(max_hours))
|
| 116 |
+
runtime_factor = {"directml": 1.65, "openvino": 1.80, "torch": 2.15}.get(runtime, 1.90)
|
| 117 |
+
foundation_budget_h = target * 0.62
|
| 118 |
+
affordable_total = int(foundation_budget_h * 3600.0 * ips / runtime_factor)
|
| 119 |
+
eval_n = counts["val"] + counts["test"]
|
| 120 |
+
train_budget = max(2000, affordable_total - eval_n)
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
fc["train_cap_per_class"] = 0
|
| 124 |
+
|
| 125 |
+
est = _estimate(cfg, counts, ips)
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
if fc["train_cap_per_class"] == 0 and est["total_hours_est"] < min(target * 0.78, max_hours - 0.5):
|
| 129 |
+
remaining_h = max(0.0, target - est["total_hours_est"])
|
| 130 |
+
extra_passes = int(remaining_h * 3600.0 * ips / runtime_factor * 0.72)
|
| 131 |
+
per_cls_extra = min(counts["train_real"], counts["train_ai"], max(0, extra_passes // 2))
|
| 132 |
+
if per_cls_extra >= 500:
|
| 133 |
+
fc["reuse_semantic_for_augmented_views"] = False
|
| 134 |
+
fc["augment_max_per_class"] = int(min(6000, per_cls_extra))
|
| 135 |
+
est = _estimate(cfg, counts, ips)
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
if est["total_hours_est"] > max_hours:
|
| 139 |
+
fc["reuse_semantic_for_augmented_views"] = True
|
| 140 |
+
fc["augment_max_per_class"] = 0
|
| 141 |
+
tc["epochs"] = min(int(tc.get("epochs",60)), 50)
|
| 142 |
+
tc["hard_refine_epochs"] = min(int(tc.get("hard_refine_epochs",6)), 4)
|
| 143 |
+
fc["train_cap_per_class"] = 0
|
| 144 |
+
rc["max_real_images"] = int(counts["train_real"])
|
| 145 |
+
est = _estimate(cfg, counts, ips)
|
| 146 |
+
|
| 147 |
+
cfg["project"]["version"] = "5.1-full-data-debiased"
|
| 148 |
+
cfg["budget"] = {
|
| 149 |
+
"target_hours": target,
|
| 150 |
+
"minimum_hours": float(min_hours),
|
| 151 |
+
"maximum_hours": float(max_hours),
|
| 152 |
+
"benchmark_runtime": runtime,
|
| 153 |
+
"foundation_images_per_second": ips,
|
| 154 |
+
"planner_note": "Estimate is hardware-measured but still approximate; disk/image decode speed can shift wall time.",
|
| 155 |
+
}
|
| 156 |
+
fc.pop("_planned_runtime", None)
|
| 157 |
+
out = Path(out_config)
|
| 158 |
+
out.parent.mkdir(parents=True, exist_ok=True)
|
| 159 |
+
out.write_text(yaml.safe_dump(cfg, allow_unicode=True, sort_keys=False), encoding="utf-8")
|
| 160 |
+
result = {
|
| 161 |
+
"counts": counts,
|
| 162 |
+
"benchmark": bench,
|
| 163 |
+
"selected": {
|
| 164 |
+
"train_cap_per_class": int(fc.get("train_cap_per_class", 0)),
|
| 165 |
+
"reuse_semantic_for_augmented_views": bool(fc.get("reuse_semantic_for_augmented_views", True)),
|
| 166 |
+
"augment_max_per_class": int(fc.get("augment_max_per_class", 0)),
|
| 167 |
+
"real_specialist_max": int(rc.get("max_real_images", 0)),
|
| 168 |
+
"ensemble_members": int(tc.get("ensemble_members", 5)),
|
| 169 |
+
"hard_refine_epochs": int(tc.get("hard_refine_epochs", 0)),
|
| 170 |
+
"all_original_train_images": True,
|
| 171 |
+
},
|
| 172 |
+
"estimate": est,
|
| 173 |
+
"output_config": str(out),
|
| 174 |
+
}
|
| 175 |
+
Path(str(out) + ".plan.json").write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding="utf-8")
|
| 176 |
+
return result
|
raven/cache.py
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
import torch
|
| 9 |
+
import torch.nn.functional as F
|
| 10 |
+
from tqdm import tqdm
|
| 11 |
+
|
| 12 |
+
from .foundation import FoundationEncoder
|
| 13 |
+
from .foundation_runtime import FoundationRuntime
|
| 14 |
+
from .forensic_features import load_rgb, extract_handcrafted, spectral_map, camera_residual_map
|
| 15 |
+
from .specialists import load_specialists
|
| 16 |
+
from .transforms import SymmetricAugment
|
| 17 |
+
from .utils import configure_cpu, seed_everything, stable_unit
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _specialist_batch_scores(spec, cam, smaps: torch.Tensor, cmaps: torch.Tensor) -> np.ndarray:
|
| 21 |
+
with torch.inference_mode():
|
| 22 |
+
ps = spec(smaps)
|
| 23 |
+
pc = cam(cmaps)
|
| 24 |
+
|
| 25 |
+
def met(pred, target):
|
| 26 |
+
err = torch.abs(pred - target)
|
| 27 |
+
flat = err.flatten(1)
|
| 28 |
+
mae = flat.mean(1)
|
| 29 |
+
mse = ((pred - target) ** 2).flatten(1).mean(1)
|
| 30 |
+
cos = 1.0 - F.cosine_similarity(pred.flatten(1), target.flatten(1), dim=1)
|
| 31 |
+
q90 = torch.quantile(flat, .90, dim=1)
|
| 32 |
+
return torch.stack([mae, mse, cos, q90], 1)
|
| 33 |
+
|
| 34 |
+
return torch.cat([met(ps, smaps), met(pc, cmaps)], 1).cpu().numpy().astype(np.float32)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _prepare_unique_row(item):
|
| 38 |
+
|
| 39 |
+
r, augment_copies, transform, feat_cfg, map_size, reuse_semantic = item
|
| 40 |
+
im = load_rgb(r["path"])
|
| 41 |
+
sem_tensor = transform(im)
|
| 42 |
+
aug = SymmetricAugment()
|
| 43 |
+
views = []
|
| 44 |
+
for v in range(0, max(0, int(augment_copies)) + 1):
|
| 45 |
+
if v > 0 and r.get("split") != "train":
|
| 46 |
+
break
|
| 47 |
+
view = im if v == 0 else aug(im)
|
| 48 |
+
h = extract_handcrafted(view, feat_cfg)
|
| 49 |
+
views.append({
|
| 50 |
+
"view_id": v,
|
| 51 |
+
"handcrafted": h,
|
| 52 |
+
"spectral_map": spectral_map(view, map_size),
|
| 53 |
+
"camera_map": camera_residual_map(view, map_size),
|
| 54 |
+
|
| 55 |
+
"semantic_tensor": None if (v == 0 or reuse_semantic) else transform(view),
|
| 56 |
+
})
|
| 57 |
+
return r, sem_tensor, views
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _prepare_unique_row_safe(item):
|
| 61 |
+
try:
|
| 62 |
+
return _prepare_unique_row(item), None
|
| 63 |
+
except Exception as exc:
|
| 64 |
+
return None, f"{item[0].get('path')}: {exc}"
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _select_augment_paths(rows: list[dict], cap_per_class: int) -> set[str]:
|
| 68 |
+
|
| 69 |
+
from collections import defaultdict
|
| 70 |
+
if cap_per_class <= 0:
|
| 71 |
+
return set()
|
| 72 |
+
chosen = set()
|
| 73 |
+
for cls in (0, 1):
|
| 74 |
+
groups = defaultdict(list)
|
| 75 |
+
for r in rows:
|
| 76 |
+
if int(r.get("label", -1)) == cls and r.get("split") == "train":
|
| 77 |
+
groups[str(r.get("source", "unknown"))].append(r)
|
| 78 |
+
if not groups:
|
| 79 |
+
continue
|
| 80 |
+
for g in groups.values():
|
| 81 |
+
g.sort(key=lambda r: stable_unit(str(r.get("path", ""))))
|
| 82 |
+
keys = sorted(groups)
|
| 83 |
+
pos = {k: 0 for k in keys}
|
| 84 |
+
count = 0
|
| 85 |
+
while count < cap_per_class:
|
| 86 |
+
added = False
|
| 87 |
+
for k in keys:
|
| 88 |
+
i = pos[k]
|
| 89 |
+
if i < len(groups[k]):
|
| 90 |
+
chosen.add(str(groups[k][i]["path"]))
|
| 91 |
+
pos[k] = i + 1
|
| 92 |
+
count += 1
|
| 93 |
+
added = True
|
| 94 |
+
if count >= cap_per_class:
|
| 95 |
+
break
|
| 96 |
+
if not added:
|
| 97 |
+
break
|
| 98 |
+
return chosen
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def _training_coreset(rows: list[dict], cap_per_class: int) -> list[dict]:
|
| 102 |
+
|
| 103 |
+
from collections import defaultdict
|
| 104 |
+
if cap_per_class <= 0:
|
| 105 |
+
return list(rows)
|
| 106 |
+
keep = [r for r in rows if str(r.get("split", "train")) != "train"]
|
| 107 |
+
for cls in (0, 1):
|
| 108 |
+
pool = [r for r in rows if str(r.get("split", "train")) == "train" and int(r.get("label", -1)) == cls]
|
| 109 |
+
if len(pool) <= cap_per_class:
|
| 110 |
+
keep.extend(pool)
|
| 111 |
+
continue
|
| 112 |
+
groups = defaultdict(list)
|
| 113 |
+
for r in pool:
|
| 114 |
+
parent = Path(str(r.get("path", ""))).parent.name
|
| 115 |
+
key = (str(r.get("source", "unknown")), str(r.get("light_bucket", "unknown")), parent)
|
| 116 |
+
groups[key].append(r)
|
| 117 |
+
for vals in groups.values():
|
| 118 |
+
vals.sort(key=lambda r: stable_unit(str(r.get("path", ""))))
|
| 119 |
+
keys = sorted(groups, key=lambda k: stable_unit("|".join(k)))
|
| 120 |
+
pos = {k: 0 for k in keys}
|
| 121 |
+
selected = []
|
| 122 |
+
while len(selected) < cap_per_class:
|
| 123 |
+
added = False
|
| 124 |
+
for k in keys:
|
| 125 |
+
i = pos[k]
|
| 126 |
+
if i < len(groups[k]):
|
| 127 |
+
selected.append(groups[k][i]); pos[k] = i + 1; added = True
|
| 128 |
+
if len(selected) >= cap_per_class:
|
| 129 |
+
break
|
| 130 |
+
if not added:
|
| 131 |
+
break
|
| 132 |
+
keep.extend(selected)
|
| 133 |
+
|
| 134 |
+
keep.sort(key=lambda r: (str(r.get("split", "train")), int(r.get("label", 0)), str(r.get("path", ""))))
|
| 135 |
+
return keep
|
| 136 |
+
|
| 137 |
+
def cache_features(rows: list[dict], cfg: dict, specialists_path: str, out_path: str, augment_copies: int = 1) -> dict:
|
| 138 |
+
hc = cfg["hardware"]
|
| 139 |
+
fc = cfg["foundation"]
|
| 140 |
+
feat_cfg = cfg["features"]
|
| 141 |
+
configure_cpu(hc.get("torch_threads", 6), hc.get("interop_threads", 1))
|
| 142 |
+
seed_everything(cfg["training"].get("seed", 1337))
|
| 143 |
+
|
| 144 |
+
input_unique = len(rows)
|
| 145 |
+
train_cap = int(fc.get("train_cap_per_class", 0))
|
| 146 |
+
if bool(fc.get("train_coreset", False)) and train_cap > 0:
|
| 147 |
+
rows = _training_coreset(rows, train_cap)
|
| 148 |
+
print(f"[cache] diversity core-set: {input_unique} -> {len(rows)} unique rows (train cap={train_cap}/class; all val/test kept)")
|
| 149 |
+
|
| 150 |
+
foundation = FoundationEncoder(
|
| 151 |
+
fc.get("backend", "dinov2_vits14"),
|
| 152 |
+
bool(fc.get("allow_fallback", True)),
|
| 153 |
+
fc.get("fallback_backend", "torchvision_convnext_tiny"),
|
| 154 |
+
)
|
| 155 |
+
foundation.eval().to("cpu")
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
p = Path(out_path)
|
| 159 |
+
p.parent.mkdir(parents=True, exist_ok=True)
|
| 160 |
+
foundation_state_path = p.with_suffix(p.suffix + ".foundation.pt")
|
| 161 |
+
torch.save(
|
| 162 |
+
{"backend": foundation.backend, "dim": int(foundation.dim), "state_dict": foundation.model.state_dict()},
|
| 163 |
+
foundation_state_path,
|
| 164 |
+
)
|
| 165 |
+
|
| 166 |
+
runtime = FoundationRuntime(fc, foundation)
|
| 167 |
+
spec, cam, sck = load_specialists(specialists_path, "cpu")
|
| 168 |
+
map_size = int(sck["map_size"])
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
bsz = max(1, int(runtime.batch_size))
|
| 172 |
+
preprocess_workers = max(0, int(hc.get("preprocess_workers", 2)))
|
| 173 |
+
reuse_sem = bool(fc.get("reuse_semantic_for_augmented_views", True))
|
| 174 |
+
augment_cap = int(fc.get("augment_max_per_class", 4000))
|
| 175 |
+
augment_paths = _select_augment_paths(rows, augment_cap) if augment_copies > 0 else set()
|
| 176 |
+
print(f"[cache] image-level augmentation selected for {len(augment_paths)} train images "
|
| 177 |
+
f"(cap={augment_cap}/class, copies={augment_copies})")
|
| 178 |
+
|
| 179 |
+
store = {k: [] for k in [
|
| 180 |
+
"semantic", "camera", "spectral", "patch", "quality", "specialist",
|
| 181 |
+
"label", "source", "path", "split", "view_id", "light_bucket", "mean_luma",
|
| 182 |
+
]}
|
| 183 |
+
|
| 184 |
+
total_unique = len(rows)
|
| 185 |
+
executor = ThreadPoolExecutor(max_workers=preprocess_workers) if preprocess_workers > 1 else None
|
| 186 |
+
|
| 187 |
+
try:
|
| 188 |
+
for start in tqdm(range(0, total_unique, bsz), desc=f"cache-features[{runtime.backend}]"):
|
| 189 |
+
chunk_rows = rows[start:start + bsz]
|
| 190 |
+
jobs = [
|
| 191 |
+
(
|
| 192 |
+
r,
|
| 193 |
+
augment_copies if str(r.get("path")) in augment_paths else 0,
|
| 194 |
+
foundation.transform, feat_cfg, map_size, reuse_sem
|
| 195 |
+
)
|
| 196 |
+
for r in chunk_rows
|
| 197 |
+
]
|
| 198 |
+
if executor is not None:
|
| 199 |
+
results = list(executor.map(_prepare_unique_row_safe, jobs))
|
| 200 |
+
prepared = []
|
| 201 |
+
for item, err in results:
|
| 202 |
+
if err:
|
| 203 |
+
print(f"[warning] skip {err}")
|
| 204 |
+
elif item is not None:
|
| 205 |
+
prepared.append(item)
|
| 206 |
+
else:
|
| 207 |
+
prepared = []
|
| 208 |
+
for j in jobs:
|
| 209 |
+
try:
|
| 210 |
+
prepared.append(_prepare_unique_row(j))
|
| 211 |
+
except Exception as exc:
|
| 212 |
+
print(f"[warning] skip {j[0].get('path')}: {exc}")
|
| 213 |
+
|
| 214 |
+
if not prepared:
|
| 215 |
+
continue
|
| 216 |
+
if not prepared:
|
| 217 |
+
continue
|
| 218 |
+
|
| 219 |
+
base_tensors = torch.stack([x[1] for x in prepared]).float()
|
| 220 |
+
semantic_base = runtime.infer(base_tensors)
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
semantic_aug = {}
|
| 224 |
+
if not reuse_sem:
|
| 225 |
+
aug_tensors = []
|
| 226 |
+
aug_keys = []
|
| 227 |
+
for pi, (_, _, views) in enumerate(prepared):
|
| 228 |
+
for vv in views:
|
| 229 |
+
if vv["view_id"] > 0 and vv["semantic_tensor"] is not None:
|
| 230 |
+
aug_tensors.append(vv["semantic_tensor"])
|
| 231 |
+
aug_keys.append((pi, vv["view_id"]))
|
| 232 |
+
for astart in range(0, len(aug_tensors), bsz):
|
| 233 |
+
aa = torch.stack(aug_tensors[astart:astart + bsz]).float()
|
| 234 |
+
yy = runtime.infer(aa)
|
| 235 |
+
for off in range(len(yy)):
|
| 236 |
+
semantic_aug[aug_keys[astart + off]] = yy[off]
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
flat_views = []
|
| 240 |
+
smaps = []
|
| 241 |
+
cmaps = []
|
| 242 |
+
for pi, (r, _, views) in enumerate(prepared):
|
| 243 |
+
for vv in views:
|
| 244 |
+
flat_views.append((pi, r, vv))
|
| 245 |
+
smaps.append(torch.from_numpy(vv["spectral_map"])[None])
|
| 246 |
+
cmaps.append(torch.from_numpy(vv["camera_map"])[None])
|
| 247 |
+
ss = _specialist_batch_scores(
|
| 248 |
+
spec,
|
| 249 |
+
cam,
|
| 250 |
+
torch.stack(smaps).float(),
|
| 251 |
+
torch.stack(cmaps).float(),
|
| 252 |
+
)
|
| 253 |
+
|
| 254 |
+
for i, (pi, r, vv) in enumerate(flat_views):
|
| 255 |
+
v = int(vv["view_id"])
|
| 256 |
+
sem = semantic_base[pi] if (v == 0 or reuse_sem) else semantic_aug[(pi, v)]
|
| 257 |
+
h = vv["handcrafted"]
|
| 258 |
+
store["semantic"].append(sem)
|
| 259 |
+
store["camera"].append(h["camera"])
|
| 260 |
+
store["spectral"].append(h["spectral"])
|
| 261 |
+
store["patch"].append(h["patch"])
|
| 262 |
+
store["quality"].append(h["quality"])
|
| 263 |
+
store["specialist"].append(ss[i])
|
| 264 |
+
store["label"].append(int(r["label"]))
|
| 265 |
+
store["source"].append(str(r.get("source", "unknown")))
|
| 266 |
+
store["path"].append(str(r["path"]))
|
| 267 |
+
store["split"].append(str(r.get("split", "train")))
|
| 268 |
+
store["view_id"].append(v)
|
| 269 |
+
store["light_bucket"].append(str(r.get("light_bucket", "unknown")))
|
| 270 |
+
store["mean_luma"].append(float(h["quality"][0]))
|
| 271 |
+
finally:
|
| 272 |
+
if executor is not None:
|
| 273 |
+
executor.shutdown(wait=True)
|
| 274 |
+
runtime.release_accelerator_memory()
|
| 275 |
+
|
| 276 |
+
arrays = {}
|
| 277 |
+
for k, v in store.items():
|
| 278 |
+
if k in {"semantic", "camera", "spectral", "patch", "quality", "specialist"}:
|
| 279 |
+
dtype = np.float16 if (k == "semantic" and bool(fc.get("cache_semantic_float16", True))) else np.float32
|
| 280 |
+
arrays[k] = np.stack(v).astype(dtype)
|
| 281 |
+
elif k in {"label", "view_id"}:
|
| 282 |
+
arrays[k] = np.asarray(v, dtype=np.int64)
|
| 283 |
+
elif k == "mean_luma":
|
| 284 |
+
arrays[k] = np.asarray(v, dtype=np.float32)
|
| 285 |
+
else:
|
| 286 |
+
arrays[k] = np.asarray(v, dtype=str)
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
if bool(fc.get("compress_feature_cache", False)):
|
| 290 |
+
np.savez_compressed(p, **arrays)
|
| 291 |
+
else:
|
| 292 |
+
np.savez(p, **arrays)
|
| 293 |
+
|
| 294 |
+
meta = {
|
| 295 |
+
"version": 5,
|
| 296 |
+
"foundation_requested": fc.get("backend"),
|
| 297 |
+
"foundation_actual": foundation.backend,
|
| 298 |
+
"foundation_runtime": runtime.backend,
|
| 299 |
+
"foundation_dim": int(foundation.dim),
|
| 300 |
+
"foundation_state": str(foundation_state_path.resolve()),
|
| 301 |
+
"specialists": str(Path(specialists_path).resolve()),
|
| 302 |
+
"augment_copies": int(augment_copies),
|
| 303 |
+
"augment_selected_images": int(len(augment_paths)),
|
| 304 |
+
"augment_max_per_class": int(augment_cap),
|
| 305 |
+
"reuse_semantic_for_augmented_views": reuse_sem,
|
| 306 |
+
"unique_images": int(total_unique),
|
| 307 |
+
"input_unique_images": int(input_unique),
|
| 308 |
+
"train_cap_per_class": int(train_cap),
|
| 309 |
+
"train_coreset": bool(fc.get("train_coreset", False)),
|
| 310 |
+
"count": int(len(arrays["label"])),
|
| 311 |
+
"dims": {k: int(arrays[k].shape[1]) for k in ["semantic", "camera", "spectral", "patch", "quality", "specialist"]},
|
| 312 |
+
}
|
| 313 |
+
p.with_suffix(p.suffix + ".meta.json").write_text(json.dumps(meta, indent=2, ensure_ascii=False), encoding="utf-8")
|
| 314 |
+
print(
|
| 315 |
+
f"[saved] {p} ({meta['count']} views from {meta['unique_images']} unique images, "
|
| 316 |
+
f"foundation={foundation.backend}, runtime={runtime.backend}, reuse_aug_semantic={reuse_sem})"
|
| 317 |
+
)
|
| 318 |
+
return meta
|
| 319 |
+
|
| 320 |
+
|
| 321 |
+
def load_cache(path: str) -> dict[str, np.ndarray]:
|
| 322 |
+
z = np.load(path, allow_pickle=False)
|
| 323 |
+
return {k: z[k] for k in z.files}
|
raven/calibration.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
import numpy as np
|
| 3 |
+
from sklearn.linear_model import LogisticRegression
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class MetaCalibrator:
|
| 7 |
+
|
| 8 |
+
def __init__(self,coef,intercept,mean=None,std=None):
|
| 9 |
+
coef=np.asarray(coef,np.float64)
|
| 10 |
+
if coef.ndim==1: coef=coef[None,:]
|
| 11 |
+
intercept=np.asarray(intercept,np.float64).reshape(-1)
|
| 12 |
+
if len(intercept)==1 and coef.shape[0]>1: intercept=np.repeat(intercept,coef.shape[0])
|
| 13 |
+
self.coef=coef; self.intercept=intercept
|
| 14 |
+
d=coef.shape[1]
|
| 15 |
+
self.mean=np.zeros(d,np.float64) if mean is None else np.asarray(mean,np.float64).reshape(-1)
|
| 16 |
+
self.std=np.ones(d,np.float64) if std is None else np.maximum(np.asarray(std,np.float64).reshape(-1),1e-8)
|
| 17 |
+
|
| 18 |
+
@staticmethod
|
| 19 |
+
def _balanced_standardization(x,y):
|
| 20 |
+
x=np.asarray(x,np.float64); y=np.asarray(y,int)
|
| 21 |
+
m0=x[y==0]; m1=x[y==1]
|
| 22 |
+
mean=.5*m0.mean(0)+.5*m1.mean(0)
|
| 23 |
+
|
| 24 |
+
second=.5*(m0*m0).mean(0)+.5*(m1*m1).mean(0)
|
| 25 |
+
std=np.sqrt(np.maximum(second-mean*mean,1e-8))
|
| 26 |
+
return mean,std
|
| 27 |
+
|
| 28 |
+
@classmethod
|
| 29 |
+
def fit(cls,x:np.ndarray,y:np.ndarray,bootstraps:int=15,seed:int=1337):
|
| 30 |
+
x=np.asarray(x,np.float64); y=np.asarray(y,int)
|
| 31 |
+
i0=np.where(y==0)[0]; i1=np.where(y==1)[0]
|
| 32 |
+
if len(i0)<4 or len(i1)<4: raise ValueError("Calibration requires both REAL and AI validation examples.")
|
| 33 |
+
mean,std=cls._balanced_standardization(x,y); z=(x-mean)/std
|
| 34 |
+
n=min(len(i0),len(i1)); rng=np.random.default_rng(seed); coefs=[]; intercepts=[]
|
| 35 |
+
for b in range(max(3,int(bootstraps))):
|
| 36 |
+
|
| 37 |
+
a=rng.choice(i0,size=n,replace=True); c=rng.choice(i1,size=n,replace=True); idx=np.r_[a,c]; rng.shuffle(idx)
|
| 38 |
+
m=LogisticRegression(C=.35,max_iter=3000,solver="lbfgs")
|
| 39 |
+
m.fit(z[idx],y[idx]); coefs.append(m.coef_[0]); intercepts.append(m.intercept_[0])
|
| 40 |
+
return cls(np.stack(coefs),np.asarray(intercepts),mean,std)
|
| 41 |
+
|
| 42 |
+
def _member_probs(self,x):
|
| 43 |
+
z=(np.asarray(x,np.float64)-self.mean)/self.std
|
| 44 |
+
logits=z@self.coef.T+self.intercept[None,:]; logits=np.clip(logits,-40,40)
|
| 45 |
+
return 1/(1+np.exp(-logits))
|
| 46 |
+
|
| 47 |
+
def predict_proba(self,x): return self._member_probs(x).mean(1)
|
| 48 |
+
|
| 49 |
+
def predict_stats(self,x,lower_q=.10,upper_q=.90):
|
| 50 |
+
p=self._member_probs(x)
|
| 51 |
+
return {
|
| 52 |
+
"mean":p.mean(1),"std":p.std(1),
|
| 53 |
+
"lower":np.quantile(p,float(lower_q),axis=1),
|
| 54 |
+
"upper":np.quantile(p,float(upper_q),axis=1),
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
def to_dict(self):
|
| 58 |
+
return {"version":2,"prior":"balanced_0.5_0.5","coef":self.coef.astype(np.float32),"intercept":self.intercept.astype(np.float32),"mean":self.mean.astype(np.float32),"std":self.std.astype(np.float32)}
|
| 59 |
+
|
| 60 |
+
@classmethod
|
| 61 |
+
def from_dict(cls,d): return cls(d["coef"],d["intercept"],d.get("mean"),d.get("std"))
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def meta_features(logit_mean,logit_std,branch_mean,gates_mean,anchors,specialist,quality=None):
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
return np.concatenate([
|
| 68 |
+
np.asarray(logit_mean).reshape(-1,1),np.asarray(logit_std).reshape(-1,1),
|
| 69 |
+
np.asarray(branch_mean),np.asarray(gates_mean),np.asarray(anchors),np.asarray(specialist),
|
| 70 |
+
],axis=1).astype(np.float32)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def select_thresholds(y,probs,cfg):
|
| 74 |
+
y=np.asarray(y,int); p=np.asarray(probs,np.float64); real=p[y==0]; ai=p[y==1]
|
| 75 |
+
if len(real)<5 or len(ai)<5: return .25,.75
|
| 76 |
+
far=float(cfg.get("target_false_ai_rate",.015)); frr=float(cfg.get("target_false_real_rate",.04))
|
| 77 |
+
|
| 78 |
+
ai_thr=float(np.quantile(real,np.clip(1-far,0,1))); real_thr=float(np.quantile(ai,np.clip(frr,0,1)))
|
| 79 |
+
ai_thr=max(ai_thr,float(cfg.get("minimum_ai_threshold",.72))); real_thr=min(real_thr,float(cfg.get("maximum_real_threshold",.28)))
|
| 80 |
+
if real_thr>=ai_thr: real_thr,ai_thr=.25,.75
|
| 81 |
+
return real_thr,ai_thr
|
raven/cli.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
import argparse,json
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
import torch
|
| 5 |
+
from .config import load_config
|
| 6 |
+
from .utils import read_jsonl
|
| 7 |
+
from .manifest import build_manifest,manifest_summary
|
| 8 |
+
from .audit import audit_manifest
|
| 9 |
+
from .specialists import train_real_specialists
|
| 10 |
+
from .cache import cache_features
|
| 11 |
+
from .training import train_fusion
|
| 12 |
+
from .evaluate import evaluate_cache
|
| 13 |
+
from .inference import infer_image
|
| 14 |
+
from .hard_mining import mine_hard
|
| 15 |
+
from .onnx_export import export_fusion,export_foundation
|
| 16 |
+
from .directml_inference import infer_directml
|
| 17 |
+
from .parquet_io import import_parquet,inspect_parquet
|
| 18 |
+
from .model import RavenFusion
|
| 19 |
+
from .diagnose import diagnose
|
| 20 |
+
from .benchmark import benchmark_foundation
|
| 21 |
+
from .budget import plan_budget
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def pjson(x): print(json.dumps(x,indent=2,ensure_ascii=False))
|
| 25 |
+
|
| 26 |
+
def cmd_build(a):
|
| 27 |
+
cfg=load_config(a.config); mc=cfg.get("manifest",{})
|
| 28 |
+
rows=build_manifest(
|
| 29 |
+
a.real_dir,a.ai_dir,a.out,a.holdout_source,
|
| 30 |
+
a.val if a.val is not None else mc.get("val_fraction",.15),
|
| 31 |
+
a.real_test if a.real_test is not None else mc.get("real_test_fraction",.15),
|
| 32 |
+
a.dedupe_distance if a.dedupe_distance is not None else mc.get("dedupe_hamming_distance",3),
|
| 33 |
+
a.parquet_cache_dir,
|
| 34 |
+
a.real_parquet_image_column,a.real_parquet_source_column,
|
| 35 |
+
a.ai_parquet_image_column,a.ai_parquet_source_column,
|
| 36 |
+
a.real_source_name if getattr(a,"real_source_name",None) else mc.get("real_source_name"),
|
| 37 |
+
a.ai_source_name if getattr(a,"ai_source_name",None) else mc.get("ai_source_name"),
|
| 38 |
+
); print(manifest_summary(rows))
|
| 39 |
+
def cmd_report(a): print(manifest_summary(read_jsonl(a.manifest)))
|
| 40 |
+
def cmd_audit(a): pjson(audit_manifest(read_jsonl(a.manifest), load_config(a.config) if getattr(a,"config",None) else None))
|
| 41 |
+
def cmd_pretrain(a): pjson(train_real_specialists(read_jsonl(a.manifest),load_config(a.config),a.out))
|
| 42 |
+
def cmd_cache(a): pjson(cache_features(read_jsonl(a.manifest),load_config(a.config),a.specialists,a.out,a.augment_copies))
|
| 43 |
+
def cmd_train(a): pjson(train_fusion(a.cache,load_config(a.config),a.out))
|
| 44 |
+
def cmd_eval(a):
|
| 45 |
+
r=evaluate_cache(a.model,a.cache,a.split); pjson({"total":r["total"],"by_source":r["by_source"],"by_light":r["by_light"]})
|
| 46 |
+
if a.report_json: Path(a.report_json).write_text(json.dumps(r,indent=2,ensure_ascii=False),encoding="utf-8")
|
| 47 |
+
def cmd_infer(a): pjson(infer_image(a.model,a.image))
|
| 48 |
+
def cmd_mine(a): pjson(mine_hard(a.model,a.cache,a.out,a.split,a.confidence))
|
| 49 |
+
def cmd_expf(a): print(export_fusion(a.model,a.out))
|
| 50 |
+
def cmd_expb(a): print(export_foundation(a.model,a.out))
|
| 51 |
+
def cmd_dml(a): pjson(infer_directml(a.model,a.foundation_onnx,a.fusion_onnx,a.image,a.cpu_only))
|
| 52 |
+
def cmd_parquet(a): pjson(import_parquet(a.parquet,a.out_dir,a.label,a.source,a.manifest_out,a.image_column))
|
| 53 |
+
def cmd_parquet_info(a): pjson(inspect_parquet(a.parquet))
|
| 54 |
+
def cmd_diag(a): pjson(diagnose())
|
| 55 |
+
def cmd_bench(a): pjson(benchmark_foundation(load_config(a.config),a.accelerator,a.loops))
|
| 56 |
+
def cmd_budget(a): pjson(plan_budget(a.manifest,a.config,a.out,a.target_hours,a.min_hours,a.max_hours,a.accelerator,a.loops))
|
| 57 |
+
|
| 58 |
+
def cmd_inspect(a):
|
| 59 |
+
ck=torch.load(a.model,map_location="cpu",weights_only=False); m=RavenFusion(ck["dims"],len(ck.get("ai_source_map",{})),len(ck.get("real_source_map",{}))); total=sum(p.numel() for p in m.parameters()); trainable=sum(p.numel() for p in m.parameters() if p.requires_grad); pjson({"fusion_parameters_per_member":total,"fusion_trainable_per_member":trainable,"ensemble_members":len(ck["ensemble_states"]),"foundation_backend":ck["foundation_backend"],"foundation_parameters_not_stored_in_fusion_count":True,"calibration_prior":"50:50 REAL/AI","validation_metrics":ck.get("validation_metrics")})
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def parser():
|
| 63 |
+
p=argparse.ArgumentParser(prog="RAVEN-F2K",description="Budget-adaptive AI-generated image detector optimized for i3-12100F / 16GB / RX 570")
|
| 64 |
+
s=p.add_subparsers(dest="cmd",required=True)
|
| 65 |
+
q=s.add_parser("build-manifest"); q.add_argument("--real-dir",required=True,help="REAL image directory OR a .parquet file; directories may also contain parquet files"); q.add_argument("--ai-dir",required=True,help="AI image directory OR a .parquet file; directories may also contain parquet files"); q.add_argument("--out",default="manifest.jsonl"); q.add_argument("--config",default="configs/raven_f2k_rx570.yaml"); q.add_argument("--holdout-source",nargs="*",default=[]); q.add_argument("--ai-source-name",help="Force every AI file to one generator/source label, e.g. gpt-image-2. Original folder label is preserved as raw_source."); q.add_argument("--real-source-name",help="Optional forced REAL source label."); q.add_argument("--val",type=float); q.add_argument("--real-test",type=float); q.add_argument("--dedupe-distance",type=int); q.add_argument("--parquet-cache-dir",default="cache/parquet_materialized"); q.add_argument("--real-parquet-image-column"); q.add_argument("--real-parquet-source-column"); q.add_argument("--ai-parquet-image-column"); q.add_argument("--ai-parquet-source-column"); q.set_defaults(func=cmd_build)
|
| 66 |
+
q=s.add_parser("report"); q.add_argument("--manifest",required=True); q.set_defaults(func=cmd_report)
|
| 67 |
+
q=s.add_parser("audit"); q.add_argument("--manifest",required=True); q.add_argument("--config",default="configs/raven_f2k_rx570_3to6h.yaml"); q.set_defaults(func=cmd_audit)
|
| 68 |
+
q=s.add_parser("pretrain-real"); q.add_argument("--manifest",required=True); q.add_argument("--config",default="configs/raven_f2k_rx570.yaml"); q.add_argument("--out",default="models/real_specialists.pt"); q.set_defaults(func=cmd_pretrain)
|
| 69 |
+
q=s.add_parser("cache-features"); q.add_argument("--manifest",required=True); q.add_argument("--specialists",default="models/real_specialists.pt"); q.add_argument("--config",default="configs/raven_f2k_rx570.yaml"); q.add_argument("--out",default="cache/features.npz"); q.add_argument("--augment-copies",type=int,default=1); q.set_defaults(func=cmd_cache)
|
| 70 |
+
q=s.add_parser("train"); q.add_argument("--cache",default="cache/features.npz"); q.add_argument("--config",default="configs/raven_f2k_rx570.yaml"); q.add_argument("--out",default="models/raven_f2k.pt"); q.set_defaults(func=cmd_train)
|
| 71 |
+
q=s.add_parser("eval"); q.add_argument("--model",default="models/raven_f2k.pt"); q.add_argument("--cache",default="cache/features.npz"); q.add_argument("--split",default="test"); q.add_argument("--report-json",default="outputs/eval.json"); q.set_defaults(func=cmd_eval)
|
| 72 |
+
q=s.add_parser("infer"); q.add_argument("--model",default="models/raven_f2k.pt"); q.add_argument("--image",required=True); q.set_defaults(func=cmd_infer)
|
| 73 |
+
q=s.add_parser("mine-hard"); q.add_argument("--model",default="models/raven_f2k.pt"); q.add_argument("--cache",default="cache/features.npz"); q.add_argument("--split",default="test"); q.add_argument("--confidence",type=float,default=.80); q.add_argument("--out",default="outputs/hard.jsonl"); q.set_defaults(func=cmd_mine)
|
| 74 |
+
q=s.add_parser("export-fusion-onnx"); q.add_argument("--model",default="models/raven_f2k.pt"); q.add_argument("--out",default="models/raven_fusion.onnx"); q.set_defaults(func=cmd_expf)
|
| 75 |
+
q=s.add_parser("export-foundation-onnx"); q.add_argument("--model",default="models/raven_f2k.pt"); q.add_argument("--out",default="models/raven_foundation.onnx"); q.set_defaults(func=cmd_expb)
|
| 76 |
+
q=s.add_parser("infer-directml"); q.add_argument("--model",default="models/raven_f2k.pt"); q.add_argument("--foundation-onnx",default="models/raven_foundation.onnx"); q.add_argument("--fusion-onnx",default="models/raven_fusion.onnx"); q.add_argument("--image",required=True); q.add_argument("--cpu-only",action="store_true"); q.set_defaults(func=cmd_dml)
|
| 77 |
+
q=s.add_parser("import-parquet"); q.add_argument("--parquet",required=True); q.add_argument("--out-dir",required=True); q.add_argument("--label",type=int,choices=[0,1],required=True); q.add_argument("--source",required=True); q.add_argument("--manifest-out",default="outputs/parquet_import.jsonl"); q.add_argument("--image-column",default="image"); q.set_defaults(func=cmd_parquet)
|
| 78 |
+
q=s.add_parser("parquet-info"); q.add_argument("--parquet",required=True); q.set_defaults(func=cmd_parquet_info)
|
| 79 |
+
q=s.add_parser("inspect-model"); q.add_argument("--model",default="models/raven_f2k.pt"); q.set_defaults(func=cmd_inspect)
|
| 80 |
+
q=s.add_parser("diagnose"); q.set_defaults(func=cmd_diag)
|
| 81 |
+
q=s.add_parser("benchmark-foundation"); q.add_argument("--config",default="configs/raven_f2k_rx570.yaml"); q.add_argument("--accelerator",choices=["auto","directml","openvino","torch"],default="auto"); q.add_argument("--loops",type=int,default=5); q.set_defaults(func=cmd_bench)
|
| 82 |
+
q=s.add_parser("plan-budget"); q.add_argument("--manifest",required=True); q.add_argument("--config",default="configs/raven_f2k_rx570_3to6h.yaml"); q.add_argument("--out",default="configs/auto_3to6h.yaml"); q.add_argument("--target-hours",type=float,default=4.5); q.add_argument("--min-hours",type=float,default=3.0); q.add_argument("--max-hours",type=float,default=6.0); q.add_argument("--accelerator",choices=["auto","directml","openvino","torch"],default="auto"); q.add_argument("--loops",type=int,default=4); q.set_defaults(func=cmd_budget)
|
| 83 |
+
return p
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def main():
|
| 87 |
+
a=parser().parse_args(); a.func(a)
|
raven/config.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
import yaml
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def load_config(path: str | Path) -> dict:
|
| 7 |
+
p = Path(path)
|
| 8 |
+
with p.open("r", encoding="utf-8") as f:
|
| 9 |
+
cfg = yaml.safe_load(f)
|
| 10 |
+
if not isinstance(cfg, dict):
|
| 11 |
+
raise ValueError(f"Invalid YAML config: {p}")
|
| 12 |
+
return cfg
|
raven/dataset.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
from collections import Counter, defaultdict
|
| 3 |
+
import math
|
| 4 |
+
import numpy as np
|
| 5 |
+
import torch
|
| 6 |
+
from torch.utils.data import Dataset, Sampler
|
| 7 |
+
|
| 8 |
+
BRANCH_KEYS=("semantic","camera","spectral","patch","specialist")
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class FeatureNormalizer:
|
| 12 |
+
def __init__(self,stats:dict): self.stats=stats
|
| 13 |
+
@classmethod
|
| 14 |
+
def fit(cls,cache:dict,mask:np.ndarray):
|
| 15 |
+
|
| 16 |
+
stats={}; labels=np.asarray(cache["label"])[mask]
|
| 17 |
+
for k in BRANCH_KEYS:
|
| 18 |
+
x=np.asarray(cache[k][mask],np.float64); x0=x[labels==0]; x1=x[labels==1]
|
| 19 |
+
if len(x0) and len(x1):
|
| 20 |
+
mean=.5*x0.mean(0)+.5*x1.mean(0); second=.5*(x0*x0).mean(0)+.5*(x1*x1).mean(0); std=np.sqrt(np.maximum(second-mean*mean,1e-10))
|
| 21 |
+
else:
|
| 22 |
+
mean=x.mean(0); std=x.std(0)
|
| 23 |
+
stats[k]={"mean":mean.astype(np.float32),"std":np.maximum(std,1e-5).astype(np.float32)}
|
| 24 |
+
return cls(stats)
|
| 25 |
+
def transform(self,k:str,x:np.ndarray)->np.ndarray:
|
| 26 |
+
s=self.stats[k]; return ((np.asarray(x,np.float32)-s["mean"])/s["std"]).astype(np.float32)
|
| 27 |
+
def to_dict(self): return self.stats
|
| 28 |
+
@classmethod
|
| 29 |
+
def from_dict(cls,d): return cls({k:{"mean":np.asarray(v["mean"],np.float32),"std":np.asarray(v["std"],np.float32)} for k,v in d.items()})
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class CachedFeatureDataset(Dataset):
|
| 33 |
+
def __init__(self,cache:dict,indices:np.ndarray,normalizer:FeatureNormalizer,anchor_scores:np.ndarray,ai_source_map:dict[str,int],real_source_map:dict[str,int]):
|
| 34 |
+
self.cache=cache; self.indices=np.asarray(indices,dtype=np.int64); self.norm=normalizer; self.anchor_scores=anchor_scores; self.ai_source_map=ai_source_map; self.real_source_map=real_source_map
|
| 35 |
+
def __len__(self): return len(self.indices)
|
| 36 |
+
def __getitem__(self,i):
|
| 37 |
+
j=int(self.indices[i]); label=int(self.cache["label"][j]); source=str(self.cache["source"][j])
|
| 38 |
+
out={k:torch.from_numpy(self.norm.transform(k,self.cache[k][j])) for k in BRANCH_KEYS}
|
| 39 |
+
out.update({
|
| 40 |
+
"quality":torch.from_numpy(np.asarray(self.cache["quality"][j],np.float32)),
|
| 41 |
+
"anchors":torch.from_numpy(np.asarray(self.anchor_scores[j],np.float32)),
|
| 42 |
+
"luma":torch.tensor(float(self.cache["mean_luma"][j]),dtype=torch.float32),
|
| 43 |
+
"label":torch.tensor(float(label),dtype=torch.float32),
|
| 44 |
+
"ai_domain":torch.tensor(self.ai_source_map.get(source,-1) if label==1 else -1,dtype=torch.long),
|
| 45 |
+
"real_domain":torch.tensor(self.real_source_map.get(source,-1) if label==0 else -1,dtype=torch.long),
|
| 46 |
+
"source":source,"path":str(self.cache["path"][j]),"view_id":int(self.cache["view_id"][j]),"index":j,
|
| 47 |
+
})
|
| 48 |
+
return out
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def make_source_maps(cache:dict,train_mask:np.ndarray):
|
| 52 |
+
labels=cache["label"]; sources=cache["source"]
|
| 53 |
+
ai=sorted({str(sources[i]) for i in np.where(train_mask & (labels==1))[0]}); real=sorted({str(sources[i]) for i in np.where(train_mask & (labels==0))[0]})
|
| 54 |
+
return {s:i for i,s in enumerate(ai)},{s:i for i,s in enumerate(real)}
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
class ClassBalancedSourceSampler(Sampler[int]):
|
| 58 |
+
|
| 59 |
+
def __init__(self, ds:CachedFeatureDataset, max_minority_oversample_factor:float=2.0, seed:int=1337):
|
| 60 |
+
self.ds=ds; self.factor=max(1.0,float(max_minority_oversample_factor)); self.seed=int(seed); self.epoch=0
|
| 61 |
+
class_source=defaultdict(lambda:defaultdict(list))
|
| 62 |
+
for local_i,j in enumerate(ds.indices):
|
| 63 |
+
label=int(ds.cache["label"][j]); source=str(ds.cache["source"][j]); class_source[label][source].append(local_i)
|
| 64 |
+
if 0 not in class_source or 1 not in class_source:
|
| 65 |
+
raise ValueError("Balanced sampler requires both REAL(label=0) and AI(label=1) in the training split.")
|
| 66 |
+
self.groups={c:{s:np.asarray(v,dtype=np.int64) for s,v in d.items()} for c,d in class_source.items()}
|
| 67 |
+
counts={c:sum(len(v) for v in d.values()) for c,d in self.groups.items()}
|
| 68 |
+
minority=min(counts[0],counts[1]); majority=max(counts[0],counts[1])
|
| 69 |
+
self.per_class=int(min(majority,max(minority,math.ceil(minority*self.factor))))
|
| 70 |
+
self.length=self.per_class*2
|
| 71 |
+
|
| 72 |
+
def __len__(self): return self.length
|
| 73 |
+
|
| 74 |
+
def _draw_class(self, cls:int, n:int, rng:np.random.Generator) -> np.ndarray:
|
| 75 |
+
|
| 76 |
+
source_names=list(self.groups[cls].keys())
|
| 77 |
+
queues={s:rng.permutation(self.groups[cls][s]) for s in source_names}
|
| 78 |
+
pos={s:0 for s in source_names}
|
| 79 |
+
result=np.empty(n,dtype=np.int64)
|
| 80 |
+
|
| 81 |
+
written=0
|
| 82 |
+
while written<n:
|
| 83 |
+
order=list(source_names); rng.shuffle(order)
|
| 84 |
+
for s in order:
|
| 85 |
+
if written>=n: break
|
| 86 |
+
q=queues[s]; i=pos[s]
|
| 87 |
+
if i>=len(q):
|
| 88 |
+
q=rng.permutation(self.groups[cls][s]); queues[s]=q; i=0
|
| 89 |
+
result[written]=int(q[i]); pos[s]=i+1; written+=1
|
| 90 |
+
return result
|
| 91 |
+
|
| 92 |
+
def __iter__(self):
|
| 93 |
+
rng=np.random.default_rng(self.seed+self.epoch*1009); self.epoch+=1
|
| 94 |
+
real=self._draw_class(0,self.per_class,rng); ai=self._draw_class(1,self.per_class,rng)
|
| 95 |
+
pair_order=rng.permutation(self.per_class)
|
| 96 |
+
seq=[]
|
| 97 |
+
for k in pair_order:
|
| 98 |
+
if rng.random()<0.5: seq.extend((int(real[k]),int(ai[k])))
|
| 99 |
+
else: seq.extend((int(ai[k]),int(real[k])))
|
| 100 |
+
return iter(seq)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def balanced_sampler(ds:CachedFeatureDataset,max_minority_oversample_factor:float=2.0,seed:int=1337):
|
| 104 |
+
return ClassBalancedSourceSampler(ds,max_minority_oversample_factor,seed)
|
raven/diagnose.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
import os
|
| 3 |
+
import platform
|
| 4 |
+
import sys
|
| 5 |
+
import torch
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def diagnose() -> dict:
|
| 9 |
+
out={
|
| 10 |
+
"python":sys.version.split()[0],
|
| 11 |
+
"platform":platform.platform(),
|
| 12 |
+
"machine":platform.machine(),
|
| 13 |
+
"logical_cpus":os.cpu_count(),
|
| 14 |
+
"torch":torch.__version__,
|
| 15 |
+
"torch_cuda_available":bool(torch.cuda.is_available()),
|
| 16 |
+
"torch_cuda_version":torch.version.cuda,
|
| 17 |
+
"mkldnn_available":bool(torch.backends.mkldnn.is_available()),
|
| 18 |
+
"recommended_cache_runtime":"DirectML on RX 570 if available; otherwise OpenVINO CPU; PyTorch fallback",
|
| 19 |
+
}
|
| 20 |
+
try:
|
| 21 |
+
import onnxruntime as ort
|
| 22 |
+
out["onnxruntime"]=ort.__version__
|
| 23 |
+
out["onnx_providers"]=ort.get_available_providers()
|
| 24 |
+
out["directml_ready"]="DmlExecutionProvider" in out["onnx_providers"]
|
| 25 |
+
except Exception:
|
| 26 |
+
out["onnxruntime"]=None; out["onnx_providers"]=[]; out["directml_ready"]=False
|
| 27 |
+
try:
|
| 28 |
+
import openvino as ov
|
| 29 |
+
out["openvino"]=getattr(ov,"__version__","installed")
|
| 30 |
+
out["openvino_devices"]=ov.Core().available_devices
|
| 31 |
+
out["openvino_ready"]="CPU" in out["openvino_devices"]
|
| 32 |
+
except Exception:
|
| 33 |
+
out["openvino"]=None; out["openvino_devices"]=[]; out["openvino_ready"]=False
|
| 34 |
+
try:
|
| 35 |
+
import pyarrow
|
| 36 |
+
out["pyarrow"]=pyarrow.__version__; out["parquet_ready"]=True
|
| 37 |
+
except Exception:
|
| 38 |
+
out["pyarrow"]=None; out["parquet_ready"]=False
|
| 39 |
+
return out
|
raven/directml_inference.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
import numpy as np
|
| 3 |
+
import torch
|
| 4 |
+
import torch.nn.functional as F
|
| 5 |
+
from .onnx_runtime import open_session,run_foundation,run_fusion
|
| 6 |
+
from .transforms import foundation_transform
|
| 7 |
+
from .forensic_features import load_rgb,extract_handcrafted,spectral_map,camera_residual_map
|
| 8 |
+
from .specialists import load_specialists_checkpoint
|
| 9 |
+
from .dataset import FeatureNormalizer,BRANCH_KEYS
|
| 10 |
+
from .anchors import AnchorSet
|
| 11 |
+
from .calibration import MetaCalibrator,meta_features
|
| 12 |
+
from .provenance import inspect_provenance
|
| 13 |
+
from .model import RavenFusion
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _specialist(im,ck):
|
| 17 |
+
spec,cam,sck=load_specialists_checkpoint(ck,"cpu"); size=int(sck["map_size"]); sm=torch.from_numpy(spectral_map(im,size)[None,None]).float(); cm=torch.from_numpy(camera_residual_map(im,size)[None,None]).float()
|
| 18 |
+
with torch.inference_mode():
|
| 19 |
+
ps=spec(sm); pc=cam(cm)
|
| 20 |
+
def m(pred,target):
|
| 21 |
+
err=torch.abs(pred-target); flat=err.flatten(1); return [float(flat.mean()),float(((pred-target)**2).flatten(1).mean()),float(1-F.cosine_similarity(pred.flatten(1),target.flatten(1),dim=1).mean()),float(torch.quantile(flat,.90))]
|
| 22 |
+
return np.asarray(m(ps,sm)+m(pc,cm),np.float32)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def infer_directml(model_path:str,foundation_onnx:str,fusion_onnx:str,image_path:str,cpu_only:bool=False):
|
| 26 |
+
ck=torch.load(model_path,map_location="cpu",weights_only=False); im=load_rgb(image_path); tf=foundation_transform(224); x=tf(im)[None].numpy().astype(np.float32)
|
| 27 |
+
fs=open_session(foundation_onnx,prefer_directml=not cpu_only); fus=open_session(fusion_onnx,prefer_directml=not cpu_only); sem=run_foundation(fs,x)[0].astype(np.float32)
|
| 28 |
+
h=extract_handcrafted(im,ck["config"]["features"]); sp=_specialist(im,ck["specialists_checkpoint"]); raw={"semantic":sem,"camera":h["camera"],"spectral":h["spectral"],"patch":h["patch"],"specialist":sp}
|
| 29 |
+
norm=FeatureNormalizer.from_dict(ck["normalizer"]); anchors=AnchorSet.from_dict(ck["anchors"]); nf={k:norm.transform(k,raw[k])[None] for k in BRANCH_KEYS}; anch=anchors.score({k:raw[k][None] for k in BRANCH_KEYS}); q=h["quality"][None].astype(np.float32); luma=np.asarray([float(h["quality"][0])],np.float32)
|
| 30 |
+
oo=run_fusion(fus,{**nf,"quality":q,"anchors":anch,"luma":luma}); mx=meta_features(oo["logit_mean"],oo["logit_std"],oo["branch_mean"],oo["gates_mean"],anch,sp[None],q); cs=MetaCalibrator.from_dict(ck["calibrator"]).predict_stats(mx); prob=float(cs["mean"][0]); pstd=float(cs["std"][0]); plo=float(cs["lower"][0]); phi=float(cs["upper"][0]); rt=float(ck["real_threshold"]); at=float(ck["ai_threshold"]); prov=inspect_provenance(image_path)
|
| 31 |
+
if prov.get("ai_provenance"): verdict="AI"; reason="verified provenance"
|
| 32 |
+
elif plo>=at: verdict="AI"; reason="balanced calibration interval is above AI threshold"
|
| 33 |
+
elif phi<=rt: verdict="REAL"; reason="balanced calibration interval is below REAL threshold"
|
| 34 |
+
else: verdict="UNCERTAIN"; reason="calibration interval overlaps the decision region"
|
| 35 |
+
gates=np.asarray(oo["gates_mean"])[0]; branches=1/(1+np.exp(-np.asarray(oo["branch_mean"])[0])); names=list(RavenFusion.BRANCHES)
|
| 36 |
+
return {"verdict":verdict,"balanced_ai_probability":prob,"balanced_real_probability":1-prob,"ai_probability":prob,"probability_definition":"50:50 REAL/AI prior; not inflated by dataset counts","calibration_std":pstd,"balanced_ai_probability_interval_80":[plo,phi],"reason":reason,"providers":{"foundation":fs.get_providers(),"fusion":fus.get_providers()},"thresholds":{"REAL_max":rt,"AI_min":at},"routing_weights":{n:float(gates[i]) for i,n in enumerate(names)},"branch_evidence":{n:float(branches[i]) for i,n in enumerate(names)},"provenance":prov}
|
raven/evaluate.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
import json
|
| 3 |
+
import numpy as np
|
| 4 |
+
import torch
|
| 5 |
+
from torch.utils.data import DataLoader
|
| 6 |
+
from .cache import load_cache
|
| 7 |
+
from .anchors import AnchorSet
|
| 8 |
+
from .dataset import FeatureNormalizer,CachedFeatureDataset
|
| 9 |
+
from .model import RavenFusion
|
| 10 |
+
from .training import collect_ensemble
|
| 11 |
+
from .calibration import MetaCalibrator,meta_features
|
| 12 |
+
from .metrics import selective_metrics,group_report
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def evaluate_cache(model_path:str,cache_path:str,split:str="test"):
|
| 16 |
+
ck=torch.load(model_path,map_location="cpu",weights_only=False); cache=load_cache(cache_path); mask=(cache["split"]==split)&(cache["view_id"]==0); idx=np.where(mask)[0]
|
| 17 |
+
if not len(idx): raise ValueError(f"No original-view samples in split={split}")
|
| 18 |
+
norm=FeatureNormalizer.from_dict(ck["normalizer"]); anchors=AnchorSet.from_dict(ck["anchors"]); anch=anchors.score(cache)
|
| 19 |
+
ds=CachedFeatureDataset(cache,idx,norm,anch,ck.get("ai_source_map",{}),ck.get("real_source_map",{})); loader=DataLoader(ds,batch_size=256,shuffle=False,num_workers=0)
|
| 20 |
+
models=[]
|
| 21 |
+
for st in ck["ensemble_states"]:
|
| 22 |
+
m=RavenFusion(ck["dims"],len(ck.get("ai_source_map",{})),len(ck.get("real_source_map",{}))); m.load_state_dict(st); m.eval(); models.append(m)
|
| 23 |
+
ens=collect_ensemble(models,loader,torch.device("cpu")); ii=ens["indices"]; mx=meta_features(ens["logit_mean"],ens["logit_std"],ens["branch_mean"],ens["gates_mean"],anch[ii],cache["specialist"][ii],cache["quality"][ii]); cstats=MetaCalibrator.from_dict(ck["calibrator"]).predict_stats(mx); probs=cstats["mean"]; lower=cstats["lower"]; upper=cstats["upper"]
|
| 24 |
+
rt=float(ck["real_threshold"]); at=float(ck["ai_threshold"]); total=selective_metrics(ens["labels"],probs,rt,at,lower,upper)
|
| 25 |
+
by_source=group_report(ens["labels"],probs,ens["sources"],rt,at,lower,upper); by_light=group_report(ens["labels"],probs,cache["light_bucket"][ii],rt,at,lower,upper)
|
| 26 |
+
pred=[]
|
| 27 |
+
for j,p,lo,hi,sd in zip(ii,probs,lower,upper,cstats["std"]):
|
| 28 |
+
verdict="AI" if lo>=at else ("REAL" if hi<=rt else "UNCERTAIN")
|
| 29 |
+
pred.append({"index":int(j),"path":str(cache["path"][j]),"label":int(cache["label"][j]),"source":str(cache["source"][j]),"light_bucket":str(cache["light_bucket"][j]),"balanced_ai_probability":float(p),"calibration_std":float(sd),"probability_interval_80":[float(lo),float(hi)],"verdict":verdict})
|
| 30 |
+
return {"total":total,"by_source":by_source,"by_light":by_light,"predictions":pred}
|
raven/forensic_features.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
import math
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
import numpy as np
|
| 5 |
+
from PIL import Image, ImageOps, ImageFilter
|
| 6 |
+
import torch
|
| 7 |
+
import torch.nn.functional as F
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def load_rgb(path: str | Path) -> Image.Image:
|
| 11 |
+
with Image.open(path) as im:
|
| 12 |
+
return ImageOps.exif_transpose(im).convert("RGB")
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def canonical_analysis_image(im: Image.Image, side: int) -> Image.Image:
|
| 16 |
+
|
| 17 |
+
x = ImageOps.exif_transpose(im).convert("RGB")
|
| 18 |
+
w, h = x.size
|
| 19 |
+
crop = max(1, min(w, h))
|
| 20 |
+
left = max(0, (w - crop) // 2); top = max(0, (h - crop) // 2)
|
| 21 |
+
x = x.crop((left, top, left + crop, top + crop))
|
| 22 |
+
if x.size != (side, side):
|
| 23 |
+
x = x.resize((side, side), Image.Resampling.LANCZOS)
|
| 24 |
+
return x
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _rgb_array(im: Image.Image, max_side: int = 384) -> np.ndarray:
|
| 28 |
+
|
| 29 |
+
x = canonical_analysis_image(im, int(max_side))
|
| 30 |
+
return np.asarray(x, dtype=np.float32) / 255.0
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _gray(a: np.ndarray) -> np.ndarray:
|
| 34 |
+
return a[..., 0] * 0.299 + a[..., 1] * 0.587 + a[..., 2] * 0.114
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _entropy01(x: np.ndarray, bins: int = 64) -> float:
|
| 38 |
+
hist, _ = np.histogram(np.clip(x, 0, 1), bins=bins, range=(0, 1), density=False)
|
| 39 |
+
p = hist.astype(np.float64); p /= max(1.0, p.sum()); p = p[p > 0]
|
| 40 |
+
return float(-(p * np.log2(p)).sum() / math.log2(bins)) if len(p) else 0.0
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def quality_features(im: Image.Image) -> np.ndarray:
|
| 44 |
+
a = _rgb_array(im, 256); y = _gray(a)
|
| 45 |
+
gx = np.diff(y, axis=1); gy = np.diff(y, axis=0)
|
| 46 |
+
edge = (float(np.mean(np.abs(gx))) + float(np.mean(np.abs(gy)))) * 0.5
|
| 47 |
+
sat = a.max(-1) - a.min(-1)
|
| 48 |
+
w, h = im.size
|
| 49 |
+
feats = [
|
| 50 |
+
float(y.mean()), float(y.std()), float(np.quantile(y, .05)), float(np.quantile(y, .95)),
|
| 51 |
+
float((y < .08).mean()), float((y < .18).mean()), float((y > .92).mean()),
|
| 52 |
+
float(sat.mean()), edge, _entropy01(y),
|
| 53 |
+
0.0, 0.0,
|
| 54 |
+
]
|
| 55 |
+
return np.asarray(feats, dtype=np.float32)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def spectral_map(im: Image.Image, size: int = 64) -> np.ndarray:
|
| 59 |
+
a = _rgb_array(im, 320); y = _gray(a)
|
| 60 |
+
f = np.fft.fftshift(np.fft.fft2(y))
|
| 61 |
+
mag = np.log1p(np.abs(f)).astype(np.float32)
|
| 62 |
+
mag = (mag - mag.mean()) / (mag.std() + 1e-6)
|
| 63 |
+
mag = np.clip(mag, -5, 5)
|
| 64 |
+
p = Image.fromarray(mag, mode="F").resize((size, size), Image.Resampling.BILINEAR)
|
| 65 |
+
out = np.asarray(p, dtype=np.float32)
|
| 66 |
+
return out / 5.0
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def camera_residual_map(im: Image.Image, size: int = 64) -> np.ndarray:
|
| 70 |
+
small = canonical_analysis_image(im, 320)
|
| 71 |
+
gray = small.convert("L")
|
| 72 |
+
blur = gray.filter(ImageFilter.GaussianBlur(radius=1.15))
|
| 73 |
+
a = np.asarray(gray, dtype=np.float32) / 255.0
|
| 74 |
+
b = np.asarray(blur, dtype=np.float32) / 255.0
|
| 75 |
+
r = a - b
|
| 76 |
+
scale = np.quantile(np.abs(r), .98) + 1e-6
|
| 77 |
+
r = np.clip(r / scale, -1, 1).astype(np.float32)
|
| 78 |
+
p = Image.fromarray(r, mode="F").resize((size, size), Image.Resampling.BILINEAR)
|
| 79 |
+
return np.asarray(p, dtype=np.float32).copy()
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def _radial_profile(mag: np.ndarray, bins: int) -> np.ndarray:
|
| 83 |
+
h, w = mag.shape
|
| 84 |
+
yy, xx = np.indices((h, w))
|
| 85 |
+
cy, cx = (h-1)/2.0, (w-1)/2.0
|
| 86 |
+
rr = np.sqrt(((yy-cy)/(max(1,h)/2.0))**2 + ((xx-cx)/(max(1,w)/2.0))**2)
|
| 87 |
+
edges = np.linspace(0, math.sqrt(2), bins+1)
|
| 88 |
+
out = np.zeros(bins, np.float32)
|
| 89 |
+
for i in range(bins):
|
| 90 |
+
mask = (rr >= edges[i]) & (rr < edges[i+1])
|
| 91 |
+
if mask.any(): out[i] = float(mag[mask].mean())
|
| 92 |
+
out -= out.mean(); out /= out.std() + 1e-6
|
| 93 |
+
return out
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def _angular_profile(mag: np.ndarray, bins: int) -> np.ndarray:
|
| 97 |
+
h, w = mag.shape; yy, xx = np.indices((h, w)); cy, cx = (h-1)/2.0, (w-1)/2.0
|
| 98 |
+
theta = np.arctan2(yy-cy, xx-cx) + np.pi
|
| 99 |
+
edges = np.linspace(0, 2*np.pi, bins+1)
|
| 100 |
+
out = np.zeros(bins, np.float32)
|
| 101 |
+
for i in range(bins):
|
| 102 |
+
mask = (theta >= edges[i]) & (theta < edges[i+1])
|
| 103 |
+
if mask.any(): out[i] = float(mag[mask].mean())
|
| 104 |
+
out -= out.mean(); out /= out.std() + 1e-6
|
| 105 |
+
return out
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def spectral_features(im: Image.Image, radial_bins: int = 32, angular_bins: int = 8) -> np.ndarray:
|
| 109 |
+
a = _rgb_array(im, 320); y = _gray(a)
|
| 110 |
+
f = np.fft.fftshift(np.fft.fft2(y)); power = np.log1p(np.abs(f)).astype(np.float32)
|
| 111 |
+
radial = _radial_profile(power, radial_bins); angular = _angular_profile(power, angular_bins)
|
| 112 |
+
h, w = power.shape; yy, xx = np.indices((h, w)); cy, cx = (h-1)/2.0, (w-1)/2.0
|
| 113 |
+
rr = np.sqrt(((yy-cy)/(h/2.0))**2 + ((xx-cx)/(w/2.0))**2)
|
| 114 |
+
low = float(power[rr < .20].mean()) if (rr < .20).any() else 0.0
|
| 115 |
+
mid = float(power[(rr >= .20) & (rr < .55)].mean()) if ((rr >= .20) & (rr < .55)).any() else 0.0
|
| 116 |
+
high = float(power[rr >= .55].mean()) if (rr >= .55).any() else 0.0
|
| 117 |
+
extras = np.asarray([
|
| 118 |
+
high/(abs(low)+1e-6), mid/(abs(low)+1e-6), high/(abs(mid)+1e-6),
|
| 119 |
+
float(power.std()), float(np.quantile(power, .95)-np.quantile(power, .05)),
|
| 120 |
+
float(np.mean(np.abs(np.diff(power, axis=0)))), float(np.mean(np.abs(np.diff(power, axis=1)))),
|
| 121 |
+
], dtype=np.float32)
|
| 122 |
+
return np.concatenate([radial, angular, extras]).astype(np.float32)
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
_LAP_KERNEL = torch.tensor([[[[0,-1,0],[-1,4,-1],[0,-1,0]]]], dtype=torch.float32)
|
| 126 |
+
|
| 127 |
+
_HP_KERNELS = torch.tensor([
|
| 128 |
+
[[0,-1,0],[-1,4,-1],[0,-1,0]],
|
| 129 |
+
[[-1,-1,-1],[0,0,0],[1,1,1]],
|
| 130 |
+
[[-1,0,1],[-1,0,1],[-1,0,1]],
|
| 131 |
+
[[-1,0,0],[0,2,0],[0,0,-1]],
|
| 132 |
+
[[0,0,-1],[0,2,0],[-1,0,0]],
|
| 133 |
+
[[-1,2,-1],[2,-4,2],[-1,2,-1]],
|
| 134 |
+
[[1,-2,1],[-2,4,-2],[1,-2,1]],
|
| 135 |
+
[[0,1,0],[1,-4,1],[0,1,0]],
|
| 136 |
+
], dtype=torch.float32)[:, None]
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def _moments(v: np.ndarray) -> list[float]:
|
| 140 |
+
v = np.asarray(v, dtype=np.float64).ravel()
|
| 141 |
+
sd = float(v.std()) + 1e-8; m = float(v.mean()); z = (v-m)/sd
|
| 142 |
+
return [float(np.mean(np.abs(v))), sd, float(np.mean(z**4)), float(np.quantile(np.abs(v), .90)), float(np.quantile(np.abs(v), .99))]
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def camera_features(im: Image.Image) -> np.ndarray:
|
| 146 |
+
a = _rgb_array(im, 256); y = _gray(a).astype(np.float32)
|
| 147 |
+
t = torch.from_numpy(y)[None, None]
|
| 148 |
+
with torch.inference_mode():
|
| 149 |
+
r = F.conv2d(t, _HP_KERNELS, padding=1)[0].numpy()
|
| 150 |
+
feats: list[float] = []
|
| 151 |
+
for i in range(r.shape[0]): feats.extend(_moments(r[i]))
|
| 152 |
+
|
| 153 |
+
chans = []
|
| 154 |
+
for c in range(3):
|
| 155 |
+
tc = torch.from_numpy(a[..., c].astype(np.float32))[None, None]
|
| 156 |
+
blur = F.avg_pool2d(tc, 3, 1, 1)
|
| 157 |
+
chans.append((tc-blur)[0,0].numpy().ravel())
|
| 158 |
+
for i, j in ((0,1),(0,2),(1,2)):
|
| 159 |
+
if np.std(chans[i]) > 1e-7 and np.std(chans[j]) > 1e-7:
|
| 160 |
+
feats.append(float(np.corrcoef(chans[i], chans[j])[0,1]))
|
| 161 |
+
else: feats.append(0.0)
|
| 162 |
+
|
| 163 |
+
residual = y - F.avg_pool2d(t, 3, 1, 1)[0,0].numpy()
|
| 164 |
+
phase_means = []; phase_stds = []
|
| 165 |
+
for py in (0,1):
|
| 166 |
+
for px in (0,1):
|
| 167 |
+
q = residual[py::2, px::2]
|
| 168 |
+
phase_means.append(float(np.mean(np.abs(q)))); phase_stds.append(float(np.std(q)))
|
| 169 |
+
feats.extend(phase_means); feats.extend(phase_stds)
|
| 170 |
+
|
| 171 |
+
lum = y.ravel(); noise = np.abs(residual).ravel()
|
| 172 |
+
if np.std(lum) > 1e-7 and np.std(noise) > 1e-7: feats.append(float(np.corrcoef(lum, noise)[0,1]))
|
| 173 |
+
else: feats.append(0.0)
|
| 174 |
+
return np.asarray(feats, dtype=np.float32)
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def _patch_micro(patch: np.ndarray) -> np.ndarray:
|
| 178 |
+
y = _gray(patch); gx = np.diff(y, axis=1); gy = np.diff(y, axis=0)
|
| 179 |
+
t = torch.from_numpy(y.astype(np.float32))[None, None]
|
| 180 |
+
blur = F.avg_pool2d(t, 3, 1, 1)[0,0].numpy(); res = y - blur
|
| 181 |
+
lap = F.conv2d(t, _LAP_KERNEL, padding=1)[0,0].numpy()
|
| 182 |
+
f = np.fft.fftshift(np.fft.fft2(y)); mag = np.abs(f); h,w=mag.shape; yy,xx=np.indices((h,w)); cy,cx=(h-1)/2,(w-1)/2
|
| 183 |
+
rr=np.sqrt(((yy-cy)/(h/2))**2+((xx-cx)/(w/2))**2); lo=float(mag[rr<.25].mean())+1e-7; hi=float(mag[rr>.60].mean())
|
| 184 |
+
return np.asarray([
|
| 185 |
+
float(y.mean()), float(y.std()), _entropy01(y),
|
| 186 |
+
float(np.mean(np.abs(gx))), float(np.std(gx)), float(np.mean(np.abs(gy))), float(np.std(gy)),
|
| 187 |
+
float(np.mean(np.abs(res))), float(np.std(res)), float(np.mean(np.abs(lap))), float(np.std(lap)), hi/lo,
|
| 188 |
+
], dtype=np.float32)
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def patch_features(im: Image.Image, patch_count: int = 8, patch_fraction: float = .32) -> np.ndarray:
|
| 192 |
+
a = _rgb_array(im, 384); h, w, _ = a.shape; short = min(h,w); cs = max(48, min(short, int(short*patch_fraction)))
|
| 193 |
+
grid = max(1, math.ceil(math.sqrt(patch_count))); xs=np.linspace(0,max(0,w-cs),grid); ys=np.linspace(0,max(0,h-cs),grid)
|
| 194 |
+
vv=[]
|
| 195 |
+
for yy in ys:
|
| 196 |
+
for xx in xs:
|
| 197 |
+
vv.append(_patch_micro(a[int(yy):int(yy)+cs, int(xx):int(xx)+cs]))
|
| 198 |
+
if len(vv)>=patch_count: break
|
| 199 |
+
if len(vv)>=patch_count: break
|
| 200 |
+
if not vv: vv=[_patch_micro(a)]
|
| 201 |
+
v=np.stack(vv); return np.concatenate([v.mean(0), v.std(0), v.max(0)]).astype(np.float32)
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def extract_handcrafted(im: Image.Image, cfg: dict) -> dict[str, np.ndarray]:
|
| 205 |
+
return {
|
| 206 |
+
"quality": quality_features(im),
|
| 207 |
+
"camera": camera_features(im),
|
| 208 |
+
"spectral": spectral_features(im, int(cfg.get("spectral_radial_bins",32)), int(cfg.get("spectral_angular_bins",8))),
|
| 209 |
+
"patch": patch_features(im, int(cfg.get("patch_count",8)), float(cfg.get("patch_fraction",.32))),
|
| 210 |
+
}
|
raven/foundation.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
import warnings
|
| 3 |
+
import torch
|
| 4 |
+
import torch.nn as nn
|
| 5 |
+
from torchvision.models import convnext_tiny, ConvNeXt_Tiny_Weights
|
| 6 |
+
from .transforms import foundation_transform
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class FoundationEncoder(nn.Module):
|
| 10 |
+
def __init__(self, backend: str = "dinov2_vits14", allow_fallback: bool = True, fallback_backend: str = "torchvision_convnext_tiny", pretrained: bool = True):
|
| 11 |
+
super().__init__()
|
| 12 |
+
self.requested_backend = backend
|
| 13 |
+
self.backend = backend
|
| 14 |
+
self.transform = foundation_transform(224)
|
| 15 |
+
self.dim = 0
|
| 16 |
+
try:
|
| 17 |
+
if backend == "dinov2_vits14":
|
| 18 |
+
self.model = torch.hub.load("facebookresearch/dinov2", "dinov2_vits14", trust_repo=True, pretrained=pretrained)
|
| 19 |
+
self.dim = 384
|
| 20 |
+
elif backend == "torchvision_convnext_tiny":
|
| 21 |
+
m = convnext_tiny(weights=ConvNeXt_Tiny_Weights.DEFAULT if pretrained else None)
|
| 22 |
+
m.classifier = nn.Identity()
|
| 23 |
+
self.model = m
|
| 24 |
+
self.dim = 768
|
| 25 |
+
else:
|
| 26 |
+
raise ValueError(f"Unknown foundation backend: {backend}")
|
| 27 |
+
except Exception as exc:
|
| 28 |
+
if not allow_fallback or backend == fallback_backend:
|
| 29 |
+
raise
|
| 30 |
+
warnings.warn(f"Foundation '{backend}' unavailable: {exc}. Falling back to {fallback_backend}.")
|
| 31 |
+
self.backend = fallback_backend
|
| 32 |
+
if fallback_backend != "torchvision_convnext_tiny":
|
| 33 |
+
raise ValueError("Only torchvision_convnext_tiny is implemented as fallback")
|
| 34 |
+
try:
|
| 35 |
+
m = convnext_tiny(weights=ConvNeXt_Tiny_Weights.DEFAULT if pretrained else None)
|
| 36 |
+
except Exception as exc2:
|
| 37 |
+
raise RuntimeError(
|
| 38 |
+
"No pretrained foundation weights are available. Connect to the internet once, "
|
| 39 |
+
"or pre-populate the torch cache. Random foundation weights are intentionally refused "
|
| 40 |
+
"because they invalidate few-shot detection."
|
| 41 |
+
) from exc2
|
| 42 |
+
m.classifier = nn.Identity()
|
| 43 |
+
self.model = m
|
| 44 |
+
self.dim = 768
|
| 45 |
+
for p in self.model.parameters():
|
| 46 |
+
p.requires_grad = False
|
| 47 |
+
self.model.eval()
|
| 48 |
+
|
| 49 |
+
@torch.inference_mode()
|
| 50 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 51 |
+
y = self.model(x)
|
| 52 |
+
if isinstance(y, dict):
|
| 53 |
+
y = y.get("x_norm_clstoken", next(iter(y.values())))
|
| 54 |
+
if y.ndim > 2:
|
| 55 |
+
y = y.flatten(1)
|
| 56 |
+
return y.float()
|
raven/foundation_runtime.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import gc
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
import torch
|
| 9 |
+
import torch.nn as nn
|
| 10 |
+
|
| 11 |
+
from .foundation import FoundationEncoder
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class _FoundationWrapper(nn.Module):
|
| 15 |
+
def __init__(self, enc: FoundationEncoder):
|
| 16 |
+
super().__init__()
|
| 17 |
+
self.enc = enc
|
| 18 |
+
|
| 19 |
+
def forward(self, x):
|
| 20 |
+
return self.enc(x)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class FoundationRuntime:
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def __init__(self, cfg: dict, encoder: FoundationEncoder):
|
| 27 |
+
self.cfg = cfg
|
| 28 |
+
self.encoder = encoder
|
| 29 |
+
self.backend = "torch"
|
| 30 |
+
self.batch_size = max(1, int(cfg.get("torch_batch_size", 8)))
|
| 31 |
+
self.runtime_cache = Path(cfg.get("runtime_cache_dir", "cache/foundation_runtime"))
|
| 32 |
+
self.runtime_cache.mkdir(parents=True, exist_ok=True)
|
| 33 |
+
self._session: Any = None
|
| 34 |
+
self._compiled: Any = None
|
| 35 |
+
|
| 36 |
+
requested = str(cfg.get("accelerator", "auto")).strip().lower()
|
| 37 |
+
order = [requested] if requested not in {"auto", ""} else ["directml", "openvino", "torch"]
|
| 38 |
+
|
| 39 |
+
errors = []
|
| 40 |
+
for candidate in order:
|
| 41 |
+
try:
|
| 42 |
+
if candidate == "directml":
|
| 43 |
+
self._init_directml()
|
| 44 |
+
elif candidate == "openvino":
|
| 45 |
+
self._init_openvino()
|
| 46 |
+
elif candidate == "torch":
|
| 47 |
+
self._init_torch()
|
| 48 |
+
else:
|
| 49 |
+
raise ValueError(f"Unknown foundation accelerator: {candidate}")
|
| 50 |
+
print(f"[foundation-runtime] {self.backend}, batch={self.batch_size}")
|
| 51 |
+
break
|
| 52 |
+
except Exception as exc:
|
| 53 |
+
errors.append(f"{candidate}: {exc}")
|
| 54 |
+
self._session = None
|
| 55 |
+
self._compiled = None
|
| 56 |
+
else:
|
| 57 |
+
raise RuntimeError("No foundation runtime could be initialized: " + " | ".join(errors))
|
| 58 |
+
|
| 59 |
+
@property
|
| 60 |
+
def model_backend(self) -> str:
|
| 61 |
+
return self.encoder.backend
|
| 62 |
+
|
| 63 |
+
@property
|
| 64 |
+
def dim(self) -> int:
|
| 65 |
+
return int(self.encoder.dim)
|
| 66 |
+
|
| 67 |
+
def _fixed_batch_onnx_path(self, batch: int) -> Path:
|
| 68 |
+
safe = self.encoder.backend.replace("/", "_")
|
| 69 |
+
return self.runtime_cache / f"{safe}_b{batch}_fixed.onnx"
|
| 70 |
+
|
| 71 |
+
def _init_directml(self):
|
| 72 |
+
import onnxruntime as ort
|
| 73 |
+
|
| 74 |
+
available = ort.get_available_providers()
|
| 75 |
+
if "DmlExecutionProvider" not in available:
|
| 76 |
+
raise RuntimeError(f"DmlExecutionProvider unavailable; providers={available}")
|
| 77 |
+
|
| 78 |
+
batch = max(1, int(self.cfg.get("directml_batch_size", 4)))
|
| 79 |
+
onnx_path = self._fixed_batch_onnx_path(batch)
|
| 80 |
+
if not onnx_path.exists():
|
| 81 |
+
print(f"[foundation-runtime] exporting fixed-batch ONNX once -> {onnx_path}")
|
| 82 |
+
wrapper = _FoundationWrapper(self.encoder).eval()
|
| 83 |
+
dummy = torch.randn(batch, 3, 224, 224, dtype=torch.float32)
|
| 84 |
+
torch.onnx.export(
|
| 85 |
+
wrapper,
|
| 86 |
+
dummy,
|
| 87 |
+
str(onnx_path),
|
| 88 |
+
input_names=["image"],
|
| 89 |
+
output_names=["embedding"],
|
| 90 |
+
opset_version=18,
|
| 91 |
+
do_constant_folding=True,
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
opts = ort.SessionOptions()
|
| 95 |
+
|
| 96 |
+
opts.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL
|
| 97 |
+
opts.enable_mem_pattern = False
|
| 98 |
+
opts.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
|
| 99 |
+
self._session = ort.InferenceSession(
|
| 100 |
+
str(onnx_path),
|
| 101 |
+
sess_options=opts,
|
| 102 |
+
providers=["DmlExecutionProvider", "CPUExecutionProvider"],
|
| 103 |
+
)
|
| 104 |
+
self.backend = "directml"
|
| 105 |
+
self.batch_size = batch
|
| 106 |
+
|
| 107 |
+
def _init_openvino(self):
|
| 108 |
+
import openvino as ov
|
| 109 |
+
|
| 110 |
+
batch = max(1, int(self.cfg.get("openvino_batch_size", 16)))
|
| 111 |
+
safe = self.encoder.backend.replace("/", "_")
|
| 112 |
+
xml = self.runtime_cache / f"{safe}_b{batch}.xml"
|
| 113 |
+
|
| 114 |
+
if not xml.exists():
|
| 115 |
+
print(f"[foundation-runtime] converting to OpenVINO IR once -> {xml}")
|
| 116 |
+
wrapper = _FoundationWrapper(self.encoder).eval()
|
| 117 |
+
dummy = torch.randn(batch, 3, 224, 224, dtype=torch.float32)
|
| 118 |
+
ov_model = ov.convert_model(wrapper, example_input=dummy)
|
| 119 |
+
ov.save_model(
|
| 120 |
+
ov_model,
|
| 121 |
+
str(xml),
|
| 122 |
+
compress_to_fp16=bool(self.cfg.get("openvino_compress_fp16", True)),
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
core = ov.Core()
|
| 126 |
+
config = {}
|
| 127 |
+
|
| 128 |
+
config["PERFORMANCE_HINT"] = "THROUGHPUT"
|
| 129 |
+
try:
|
| 130 |
+
config["INFERENCE_NUM_THREADS"] = int(self.cfg.get("openvino_threads", 6))
|
| 131 |
+
except Exception:
|
| 132 |
+
pass
|
| 133 |
+
try:
|
| 134 |
+
self._compiled = core.compile_model(str(xml), "CPU", config)
|
| 135 |
+
except Exception:
|
| 136 |
+
|
| 137 |
+
self._compiled = core.compile_model(str(xml), "CPU", {"PERFORMANCE_HINT": "THROUGHPUT"})
|
| 138 |
+
self.backend = "openvino"
|
| 139 |
+
self.batch_size = batch
|
| 140 |
+
|
| 141 |
+
def _init_torch(self):
|
| 142 |
+
self.encoder.eval().to("cpu")
|
| 143 |
+
self.backend = "torch"
|
| 144 |
+
self.batch_size = max(1, int(self.cfg.get("torch_batch_size", 8)))
|
| 145 |
+
|
| 146 |
+
def _pad(self, x: np.ndarray | torch.Tensor) -> tuple[np.ndarray, int]:
|
| 147 |
+
if isinstance(x, torch.Tensor):
|
| 148 |
+
a = x.detach().cpu().numpy().astype(np.float32, copy=False)
|
| 149 |
+
else:
|
| 150 |
+
a = np.asarray(x, dtype=np.float32)
|
| 151 |
+
n = int(a.shape[0])
|
| 152 |
+
if n == self.batch_size:
|
| 153 |
+
return a, n
|
| 154 |
+
if n > self.batch_size:
|
| 155 |
+
raise ValueError(f"Runtime batch {n} exceeds configured fixed batch {self.batch_size}")
|
| 156 |
+
pad = np.zeros((self.batch_size,) + tuple(a.shape[1:]), dtype=np.float32)
|
| 157 |
+
pad[:n] = a
|
| 158 |
+
return pad, n
|
| 159 |
+
|
| 160 |
+
@torch.inference_mode()
|
| 161 |
+
def infer(self, x: torch.Tensor) -> np.ndarray:
|
| 162 |
+
if self.backend == "torch":
|
| 163 |
+
return self.encoder(x.float()).cpu().numpy().astype(np.float32)
|
| 164 |
+
|
| 165 |
+
a, n = self._pad(x)
|
| 166 |
+
if self.backend == "directml":
|
| 167 |
+
y = self._session.run(["embedding"], {"image": a})[0]
|
| 168 |
+
elif self.backend == "openvino":
|
| 169 |
+
result = self._compiled([a])
|
| 170 |
+
y = result[self._compiled.output(0)]
|
| 171 |
+
else:
|
| 172 |
+
raise RuntimeError(self.backend)
|
| 173 |
+
return np.asarray(y[:n], dtype=np.float32)
|
| 174 |
+
|
| 175 |
+
def release_accelerator_memory(self):
|
| 176 |
+
|
| 177 |
+
self._session = None
|
| 178 |
+
self._compiled = None
|
| 179 |
+
gc.collect()
|
raven/hard_mining.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
from .evaluate import evaluate_cache
|
| 3 |
+
from .utils import write_jsonl
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def mine_hard(model_path:str,cache_path:str,out_path:str,split:str="test",confidence:float=.80):
|
| 7 |
+
report=evaluate_cache(model_path,cache_path,split); hard=[]
|
| 8 |
+
for r in report["predictions"]:
|
| 9 |
+
p=float(r.get("balanced_ai_probability",r.get("ai_probability",.5))); y=int(r["label"]); v=r["verdict"]
|
| 10 |
+
wrong_confident=(y==0 and p>=confidence) or (y==1 and p<=1-confidence)
|
| 11 |
+
uncertain=v=="UNCERTAIN"
|
| 12 |
+
if wrong_confident or uncertain:
|
| 13 |
+
x=dict(r); x["hard_type"]="confident_wrong" if wrong_confident else "uncertain"; x["hard_weight"]=2.0 if wrong_confident else 1.35; hard.append(x)
|
| 14 |
+
write_jsonl(out_path,hard); return {"count":len(hard),"split":split,"output":out_path,"metrics":report["total"]}
|
raven/inference.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
import json
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
import numpy as np
|
| 5 |
+
import torch
|
| 6 |
+
from .foundation import FoundationEncoder
|
| 7 |
+
from .forensic_features import load_rgb,extract_handcrafted,spectral_map,camera_residual_map
|
| 8 |
+
from .specialists import load_specialists_checkpoint
|
| 9 |
+
from .anchors import AnchorSet
|
| 10 |
+
from .dataset import FeatureNormalizer,BRANCH_KEYS
|
| 11 |
+
from .model import RavenFusion
|
| 12 |
+
from .calibration import MetaCalibrator,meta_features
|
| 13 |
+
from .provenance import inspect_provenance
|
| 14 |
+
from .utils import configure_cpu
|
| 15 |
+
import torch.nn.functional as F
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def load_detector(path:str):
|
| 19 |
+
ck=torch.load(path,map_location="cpu",weights_only=False); cfg=ck["config"]; configure_cpu(cfg["hardware"].get("torch_threads",6),cfg["hardware"].get("interop_threads",1))
|
| 20 |
+
foundation=FoundationEncoder(ck["foundation_backend"],allow_fallback=False,fallback_backend=ck["foundation_backend"],pretrained=ck.get("foundation_state") is None).eval().to("cpu")
|
| 21 |
+
if ck.get("foundation_state") is not None:
|
| 22 |
+
foundation.model.load_state_dict(ck["foundation_state"]["state_dict"])
|
| 23 |
+
if ck.get("specialists_checkpoint") is None: raise ValueError("Detector checkpoint has no embedded real-specialist model. Re-run cache/train with v2 pipeline.")
|
| 24 |
+
spec,cam,sck=load_specialists_checkpoint(ck["specialists_checkpoint"],"cpu")
|
| 25 |
+
models=[]
|
| 26 |
+
for st in ck["ensemble_states"]:
|
| 27 |
+
m=RavenFusion(ck["dims"],len(ck.get("ai_source_map",{})),len(ck.get("real_source_map",{}))); m.load_state_dict(st); m.eval(); models.append(m)
|
| 28 |
+
return ck,foundation,(spec,cam,sck),models,FeatureNormalizer.from_dict(ck["normalizer"]),AnchorSet.from_dict(ck["anchors"]),MetaCalibrator.from_dict(ck["calibrator"])
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _specialist_score_batch(im,spec,cam,sck):
|
| 32 |
+
size=int(sck["map_size"]); sm=torch.from_numpy(spectral_map(im,size)[None,None]).float(); cm=torch.from_numpy(camera_residual_map(im,size)[None,None]).float()
|
| 33 |
+
with torch.inference_mode():
|
| 34 |
+
ps=spec(sm); pc=cam(cm)
|
| 35 |
+
def met(pred,target):
|
| 36 |
+
err=torch.abs(pred-target); flat=err.flatten(1); return [float(flat.mean()),float(((pred-target)**2).flatten(1).mean()),float(1-F.cosine_similarity(pred.flatten(1),target.flatten(1),dim=1).mean()),float(torch.quantile(flat,.90))]
|
| 37 |
+
return np.asarray(met(ps,sm)+met(pc,cm),np.float32)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def extract_single(path:str,ck,foundation,specialists,normalizer,anchors):
|
| 41 |
+
im=load_rgb(path); h=extract_handcrafted(im,ck["config"]["features"]); sem=None
|
| 42 |
+
with torch.inference_mode(): sem=foundation(foundation.transform(im)[None].float()).cpu().numpy()[0].astype(np.float32)
|
| 43 |
+
spec,cam,sck=specialists; sp=_specialist_score_batch(im,spec,cam,sck)
|
| 44 |
+
raw={"semantic":sem,"camera":h["camera"],"spectral":h["spectral"],"patch":h["patch"],"specialist":sp}
|
| 45 |
+
norm={k:normalizer.transform(k,raw[k])[None] for k in BRANCH_KEYS}; anch=anchors.score({k:raw[k][None] for k in BRANCH_KEYS})
|
| 46 |
+
q=h["quality"][None].astype(np.float32); luma=np.asarray([float(h["quality"][0])],np.float32)
|
| 47 |
+
return raw,norm,anch,q,luma
|
| 48 |
+
|
| 49 |
+
@torch.inference_mode()
|
| 50 |
+
def infer_image(model_path:str,image_path:str) -> dict:
|
| 51 |
+
ck,foundation,specialists,models,norm,anchors,cal=load_detector(model_path); raw,features,anch,q,luma=extract_single(image_path,ck,foundation,specialists,norm,anchors)
|
| 52 |
+
tf={k:torch.from_numpy(v).float() for k,v in features.items()}; tq=torch.from_numpy(q); ta=torch.from_numpy(anch); tl=torch.from_numpy(luma)
|
| 53 |
+
outs=[m(tf,tq,ta,tl,0.0,False) for m in models]; logits=np.asarray([float(o["logit"][0]) for o in outs],np.float32); branches=np.stack([o["branch_logits"][0].numpy() for o in outs]); gates=np.stack([o["gates"][0].numpy() for o in outs])
|
| 54 |
+
mx=meta_features(np.asarray([logits.mean()]),np.asarray([logits.std()]),branches.mean(0)[None],gates.mean(0)[None],anch,raw["specialist"][None],q); cstats=cal.predict_stats(mx); prob=float(cstats["mean"][0]); pstd=float(cstats["std"][0]); plo=float(cstats["lower"][0]); phi=float(cstats["upper"][0])
|
| 55 |
+
prov=inspect_provenance(image_path); rt=float(ck["real_threshold"]); at=float(ck["ai_threshold"])
|
| 56 |
+
if prov.get("ai_provenance"): verdict="AI"; reason="verified provenance indicates algorithmic generation"
|
| 57 |
+
elif plo>=at: verdict="AI"; reason="the balanced-prior calibration interval is entirely above the AI threshold"
|
| 58 |
+
elif phi<=rt: verdict="REAL"; reason="the balanced-prior calibration interval is entirely below the REAL threshold"
|
| 59 |
+
else: verdict="UNCERTAIN"; reason="calibration uncertainty overlaps the decision region"
|
| 60 |
+
bnames=list(RavenFusion.BRANCHES); gate_mean=gates.mean(0); branch_prob=1/(1+np.exp(-branches.mean(0)))
|
| 61 |
+
return {"verdict":verdict,"balanced_ai_probability":prob,"balanced_real_probability":1-prob,"ai_probability":prob,"real_probability":1-prob,"probability_definition":"50:50 REAL/AI prior; not inflated by training file counts","calibration_std":pstd,"balanced_ai_probability_interval_80":[plo,phi],"reason":reason,"ensemble_logit_std":float(logits.std()),"thresholds":{"REAL_max":rt,"AI_min":at},"foundation":ck["foundation_backend"],"mean_luma":float(luma[0]),"darkness":float(np.clip((.25-luma[0])/.25,0,1)),"branch_evidence":{b:float(branch_prob[i]) for i,b in enumerate(bnames)},"routing_weights":{b:float(gate_mean[i]) for i,b in enumerate(bnames)},"real_anchor_z":{b:float(anch[0,i]) for i,b in enumerate(bnames)},"specialist_errors":{"spectral_MAE":float(raw["specialist"][0]),"spectral_MSE":float(raw["specialist"][1]),"spectral_cosine_error":float(raw["specialist"][2]),"spectral_Q90":float(raw["specialist"][3]),"camera_MAE":float(raw["specialist"][4]),"camera_MSE":float(raw["specialist"][5]),"camera_cosine_error":float(raw["specialist"][6]),"camera_Q90":float(raw["specialist"][7])},"provenance":prov}
|
raven/manifest.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
from collections import Counter
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
import numpy as np
|
| 5 |
+
from PIL import Image, ImageOps
|
| 6 |
+
try:
|
| 7 |
+
import imagehash
|
| 8 |
+
except Exception:
|
| 9 |
+
imagehash = None
|
| 10 |
+
from tqdm import tqdm
|
| 11 |
+
from .utils import iter_images, stable_unit, write_jsonl
|
| 12 |
+
from .parquet_io import discover_parquet_files, materialize_parquet
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _phash(path: Path, hash_size: int = 12) -> str | None:
|
| 16 |
+
try:
|
| 17 |
+
with Image.open(path) as im:
|
| 18 |
+
im = ImageOps.exif_transpose(im).convert("RGB")
|
| 19 |
+
if imagehash is not None:
|
| 20 |
+
return str(imagehash.phash(im, hash_size=hash_size))
|
| 21 |
+
g = im.convert("L").resize((32, 32), Image.Resampling.LANCZOS)
|
| 22 |
+
a = np.asarray(g, dtype=np.float32) / 255.0
|
| 23 |
+
low = np.abs(np.fft.fft2(a))[:hash_size, :hash_size]
|
| 24 |
+
flat = low.ravel(); med = float(np.median(flat[1:])) if flat.size > 1 else float(flat[0])
|
| 25 |
+
bits = (flat > med).astype(np.uint8)
|
| 26 |
+
value = 0
|
| 27 |
+
for bit in bits: value = (value << 1) | int(bit)
|
| 28 |
+
width = (len(bits) + 3) // 4
|
| 29 |
+
return f"{value:0{width}x}"
|
| 30 |
+
except Exception:
|
| 31 |
+
return None
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _hamming_hex(a: str, b: str) -> int:
|
| 35 |
+
try: return (int(a, 16) ^ int(b, 16)).bit_count()
|
| 36 |
+
except Exception: return 9999
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class _HammingIndex:
|
| 40 |
+
|
| 41 |
+
def __init__(self, max_distance:int):
|
| 42 |
+
self.d=max(0,int(max_distance)); self.values=[]; self.buckets={}
|
| 43 |
+
|
| 44 |
+
def _segments(self, value:int, bits:int):
|
| 45 |
+
parts=max(1,self.d+1); base=bits//parts; rem=bits%parts; shift=0
|
| 46 |
+
for i in range(parts):
|
| 47 |
+
width=base+(1 if i<rem else 0); mask=(1<<width)-1
|
| 48 |
+
yield i,(value>>shift)&mask
|
| 49 |
+
shift+=width
|
| 50 |
+
|
| 51 |
+
def duplicate_or_add(self, ph:str|None)->bool:
|
| 52 |
+
if not ph or self.d<0: return False
|
| 53 |
+
try: value=int(ph,16)
|
| 54 |
+
except Exception: return False
|
| 55 |
+
bits=max(4,len(ph)*4); cand=set(); segs=list(self._segments(value,bits))
|
| 56 |
+
for key in segs:
|
| 57 |
+
cand.update(self.buckets.get(key,()))
|
| 58 |
+
for idx in cand:
|
| 59 |
+
if (value^self.values[idx]).bit_count()<=self.d:
|
| 60 |
+
return True
|
| 61 |
+
idx=len(self.values); self.values.append(value)
|
| 62 |
+
for key in segs:
|
| 63 |
+
self.buckets.setdefault(key,[]).append(idx)
|
| 64 |
+
return False
|
| 65 |
+
|
| 66 |
+
def _stats(path: Path) -> dict:
|
| 67 |
+
try:
|
| 68 |
+
with Image.open(path) as im:
|
| 69 |
+
fmt = (im.format or path.suffix.lstrip(".")).upper(); exif = im.getexif(); has_exif = bool(exif)
|
| 70 |
+
im = ImageOps.exif_transpose(im).convert("RGB"); w, h = im.size
|
| 71 |
+
tiny = im.copy(); tiny.thumbnail((192, 192)); a = np.asarray(tiny, dtype=np.float32) / 255.0
|
| 72 |
+
y = a[..., 0] * 0.2126 + a[..., 1] * 0.7152 + a[..., 2] * 0.0722
|
| 73 |
+
mean = float(y.mean()); std = float(y.std()); dark_fraction = float((y < 0.12).mean())
|
| 74 |
+
if mean < 0.08: bucket = "extreme-dark"
|
| 75 |
+
elif mean < 0.20: bucket = "dark"
|
| 76 |
+
elif mean < 0.38: bucket = "dim"
|
| 77 |
+
else: bucket = "normal"
|
| 78 |
+
return {"width":int(w),"height":int(h),"megapixels":float(w*h/1e6),"aspect_ratio":float(w/max(1,h)),"format":fmt,"has_exif":has_exif,"mean_luma":mean,"std_luma":std,"dark_fraction":dark_fraction,"light_bucket":bucket}
|
| 79 |
+
except Exception:
|
| 80 |
+
return {"width":None,"height":None,"megapixels":None,"aspect_ratio":None,"format":path.suffix.lstrip(".").upper(),"has_exif":False,"mean_luma":None,"std_luma":None,"dark_fraction":None,"light_bucket":"unknown"}
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _normal_descriptors(root_spec:str|Path,label:int) -> list[dict]:
|
| 84 |
+
root=Path(root_spec).resolve(); out=[]
|
| 85 |
+
if not root.exists(): return out
|
| 86 |
+
|
| 87 |
+
if root.is_file(): return out
|
| 88 |
+
for p in iter_images(root):
|
| 89 |
+
try: rel=p.relative_to(root)
|
| 90 |
+
except Exception: continue
|
| 91 |
+
source_leaf=rel.parts[0] if len(rel.parts)>1 else ("unknown-camera" if label==0 else "unknown-generator")
|
| 92 |
+
out.append({"path":str(p.resolve()),"label":int(label),"source_leaf":source_leaf,"group":str(rel.with_suffix("")),"origin_kind":"image","origin_parquet":None,"origin_row":None})
|
| 93 |
+
return out
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def _parquet_descriptors(root_spec:str|Path,label:int,cache_root:Path,image_column:str|None,source_column:str|None) -> list[dict]:
|
| 97 |
+
out=[]
|
| 98 |
+
for pq in discover_parquet_files(root_spec):
|
| 99 |
+
default_source=pq.stem
|
| 100 |
+
rows=materialize_parquet(pq,cache_root,label,default_source,image_column,source_column)
|
| 101 |
+
out.extend(rows)
|
| 102 |
+
return out
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def build_manifest(
|
| 106 |
+
real_dir: str,
|
| 107 |
+
ai_dir: str,
|
| 108 |
+
out: str,
|
| 109 |
+
holdout_sources: list[str] | None = None,
|
| 110 |
+
val_fraction: float = 0.15,
|
| 111 |
+
real_test_fraction: float = 0.15,
|
| 112 |
+
dedupe_hamming_distance: int = 3,
|
| 113 |
+
parquet_cache_dir: str | None = None,
|
| 114 |
+
real_parquet_image_column: str | None = None,
|
| 115 |
+
real_parquet_source_column: str | None = None,
|
| 116 |
+
ai_parquet_image_column: str | None = None,
|
| 117 |
+
ai_parquet_source_column: str | None = None,
|
| 118 |
+
force_real_source: str | None = None,
|
| 119 |
+
force_ai_source: str | None = None,
|
| 120 |
+
) -> list[dict]:
|
| 121 |
+
holdout = {s.strip().lower() for s in (holdout_sources or []) if s.strip()}
|
| 122 |
+
rows=[]; dedupe_index=_HammingIndex(dedupe_hamming_distance)
|
| 123 |
+
manifest_parent=Path(out).resolve().parent
|
| 124 |
+
pq_cache=Path(parquet_cache_dir).resolve() if parquet_cache_dir else (manifest_parent/"cache"/"parquet_materialized")
|
| 125 |
+
pq_cache.mkdir(parents=True,exist_ok=True)
|
| 126 |
+
|
| 127 |
+
def duplicate(ph):
|
| 128 |
+
if dedupe_hamming_distance < 0:
|
| 129 |
+
return False
|
| 130 |
+
return dedupe_index.duplicate_or_add(ph)
|
| 131 |
+
|
| 132 |
+
specs=[
|
| 133 |
+
(0,real_dir,"real",real_parquet_image_column,real_parquet_source_column),
|
| 134 |
+
(1,ai_dir,"ai",ai_parquet_image_column,ai_parquet_source_column),
|
| 135 |
+
]
|
| 136 |
+
for label,root_spec,kind,img_col,src_col in specs:
|
| 137 |
+
descriptors=_normal_descriptors(root_spec,label)
|
| 138 |
+
pq_desc=_parquet_descriptors(root_spec,label,pq_cache/kind,img_col,src_col)
|
| 139 |
+
descriptors.extend(pq_desc)
|
| 140 |
+
print(f"[{kind}] normal_images={len(descriptors)-len(pq_desc)} parquet_images={len(pq_desc)}")
|
| 141 |
+
for d in tqdm(descriptors,desc=f"manifest-{kind}"):
|
| 142 |
+
p=Path(d["path"]); raw_source_leaf=str(d.get("source_leaf") or ("unknown-camera" if label==0 else "unknown-generator"))
|
| 143 |
+
forced = force_real_source if label==0 else force_ai_source
|
| 144 |
+
source_leaf = str(forced).strip() if forced and str(forced).strip() else raw_source_leaf
|
| 145 |
+
ph=_phash(p)
|
| 146 |
+
if duplicate(ph): continue
|
| 147 |
+
group=str(d.get("group") or p.stem)
|
| 148 |
+
if label==1 and source_leaf.lower() in holdout: split="test"
|
| 149 |
+
else:
|
| 150 |
+
r=stable_unit(f"{kind}:{group}")
|
| 151 |
+
if label==0:
|
| 152 |
+
if r<real_test_fraction: split="test"
|
| 153 |
+
elif r<real_test_fraction+val_fraction: split="val"
|
| 154 |
+
else: split="train"
|
| 155 |
+
else: split="val" if r<val_fraction else "train"
|
| 156 |
+
rows.append({
|
| 157 |
+
"path":str(p.resolve()),"label":int(label),
|
| 158 |
+
"source":(f"real:{source_leaf}" if label==0 else source_leaf),
|
| 159 |
+
"raw_source":raw_source_leaf,
|
| 160 |
+
"generator":source_leaf if label==1 else None,"camera_bucket":source_leaf if label==0 else None,
|
| 161 |
+
"group":group,"phash":ph,"split":split,
|
| 162 |
+
"origin_kind":d.get("origin_kind","image"),"origin_parquet":d.get("origin_parquet"),"origin_row":d.get("origin_row"),
|
| 163 |
+
**_stats(p),
|
| 164 |
+
})
|
| 165 |
+
write_jsonl(out,rows); return rows
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def manifest_summary(rows:list[dict])->str:
|
| 169 |
+
c=Counter((r.get("split"),int(r.get("label",-1))) for r in rows); src=Counter((r.get("split"),str(r.get("source","unknown"))) for r in rows); origin=Counter((r.get("split"),str(r.get("origin_kind","image"))) for r in rows)
|
| 170 |
+
lines=["Counts by split / label (0=REAL, 1=AI):"]
|
| 171 |
+
for k,v in sorted(c.items(),key=lambda kv:str(kv[0])): lines.append(f" {k}: {v}")
|
| 172 |
+
lines.append("\nCounts by split / source:")
|
| 173 |
+
for k,v in sorted(src.items(),key=lambda kv:str(kv[0])): lines.append(f" {k}: {v}")
|
| 174 |
+
lines.append("\nCounts by split / storage origin:")
|
| 175 |
+
for k,v in sorted(origin.items(),key=lambda kv:str(kv[0])): lines.append(f" {k}: {v}")
|
| 176 |
+
return "\n".join(lines)
|
raven/metrics.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
from collections import defaultdict
|
| 3 |
+
import numpy as np
|
| 4 |
+
from sklearn.metrics import roc_auc_score,average_precision_score,accuracy_score,balanced_accuracy_score
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def _balanced_weights(y):
|
| 8 |
+
y=np.asarray(y,int); w=np.zeros(len(y),float)
|
| 9 |
+
for c in (0,1):
|
| 10 |
+
m=y==c
|
| 11 |
+
if m.any(): w[m]=.5/m.sum()
|
| 12 |
+
return w
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def ece(y,p,bins=12,balanced=True):
|
| 16 |
+
y=np.asarray(y,int); p=np.asarray(p,float); w=_balanced_weights(y) if balanced and len(np.unique(y))==2 else np.ones(len(y))/max(1,len(y)); out=0.0
|
| 17 |
+
edges=np.linspace(0,1,bins+1)
|
| 18 |
+
for lo,hi in zip(edges[:-1],edges[1:]):
|
| 19 |
+
m=(p>=lo)&(p<(hi if hi<1 else 1.00001))
|
| 20 |
+
if not m.any(): continue
|
| 21 |
+
ww=w[m]; sw=ww.sum()
|
| 22 |
+
if sw<=0: continue
|
| 23 |
+
conf=float(np.sum(ww*p[m])/sw); acc=float(np.sum(ww*y[m])/sw); out+=float(sw)*abs(acc-conf)
|
| 24 |
+
return float(out/max(w.sum(),1e-12))
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def selective_metrics(y,p,real_thr,ai_thr,lower=None,upper=None):
|
| 28 |
+
y=np.asarray(y,int); p=np.asarray(p,float); lo=p if lower is None else np.asarray(lower,float); hi=p if upper is None else np.asarray(upper,float)
|
| 29 |
+
pred=np.full(len(y),-1,int); pred[hi<=real_thr]=0; pred[lo>=ai_thr]=1; known=pred>=0; real=y==0; ai=y==1
|
| 30 |
+
both=len(np.unique(y))==2; bw=_balanced_weights(y) if both else None
|
| 31 |
+
out={
|
| 32 |
+
"n":int(len(y)),"accuracy@0.5_raw":float(accuracy_score(y,p>=.5)),
|
| 33 |
+
"balanced_accuracy@0.5":float(balanced_accuracy_score(y,p>=.5)) if both else None,
|
| 34 |
+
"AUROC":float(roc_auc_score(y,p)) if both else None,
|
| 35 |
+
"AP_raw_prevalence_sensitive":float(average_precision_score(y,p)) if both else None,
|
| 36 |
+
"AP_balanced":float(average_precision_score(y,p,sample_weight=bw)) if both else None,
|
| 37 |
+
"balanced_Brier":float(np.sum(bw*(p-y)**2)) if both else None,
|
| 38 |
+
"coverage":float(known.mean()),"selective_accuracy_raw":float((pred[known]==y[known]).mean()) if known.any() else None,
|
| 39 |
+
"false_AI_rate_REAL_to_AI":float((lo[real]>=ai_thr).mean()) if real.any() else None,
|
| 40 |
+
"false_REAL_rate_AI_to_REAL":float((hi[ai]<=real_thr).mean()) if ai.any() else None,
|
| 41 |
+
"AI_confirmed_recall":float((lo[ai]>=ai_thr).mean()) if ai.any() else None,
|
| 42 |
+
"REAL_confirmed_recall":float((hi[real]<=real_thr).mean()) if real.any() else None,
|
| 43 |
+
"uncertain_rate":float((~known).mean()),"ECE_balanced":float(ece(y,p,balanced=True)) if both else None,
|
| 44 |
+
"real_threshold":float(real_thr),"ai_threshold":float(ai_thr),
|
| 45 |
+
}
|
| 46 |
+
if known.any() and both:
|
| 47 |
+
wk=_balanced_weights(y[known]); out["selective_accuracy_balanced"]=float(np.sum(wk*(pred[known]==y[known]))/max(wk.sum(),1e-12))
|
| 48 |
+
else: out["selective_accuracy_balanced"]=out["selective_accuracy_raw"]
|
| 49 |
+
return out
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def group_report(y,p,groups,real_thr,ai_thr,lower=None,upper=None):
|
| 53 |
+
by=defaultdict(list); y=np.asarray(y); p=np.asarray(p); lower=p if lower is None else np.asarray(lower); upper=p if upper is None else np.asarray(upper)
|
| 54 |
+
for i,g in enumerate(groups): by[str(g)].append(i)
|
| 55 |
+
out=[]
|
| 56 |
+
for g,idx in sorted(by.items()):
|
| 57 |
+
ii=np.asarray(idx,int); m=selective_metrics(y[ii],p[ii],real_thr,ai_thr,lower[ii],upper[ii]); m["group"]=g; out.append(m)
|
| 58 |
+
return out
|
raven/model.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
import torch
|
| 3 |
+
import torch.nn as nn
|
| 4 |
+
import torch.nn.functional as F
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class GradReverseFn(torch.autograd.Function):
|
| 8 |
+
@staticmethod
|
| 9 |
+
def forward(ctx,x,lambd): ctx.lambd=float(lambd); return x.view_as(x)
|
| 10 |
+
@staticmethod
|
| 11 |
+
def backward(ctx,g): return -ctx.lambd*g,None
|
| 12 |
+
|
| 13 |
+
def grad_reverse(x,lambd): return GradReverseFn.apply(x,lambd)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class Tower(nn.Module):
|
| 17 |
+
def __init__(self,din:int,dout:int,dropout:float=.12):
|
| 18 |
+
super().__init__(); self.net=nn.Sequential(nn.LayerNorm(din),nn.Linear(din,dout),nn.GELU(),nn.Dropout(dropout),nn.Linear(dout,dout),nn.GELU())
|
| 19 |
+
def forward(self,x): return self.net(x)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class RavenFusion(nn.Module):
|
| 23 |
+
|
| 24 |
+
BRANCHES=("semantic","camera","spectral","patch","specialist")
|
| 25 |
+
def __init__(self,dims:dict,num_ai_domains:int=0,num_real_domains:int=0,latent_dim:int=64):
|
| 26 |
+
super().__init__()
|
| 27 |
+
outdims={"semantic":96,"camera":48,"spectral":48,"patch":48,"specialist":24}
|
| 28 |
+
self.towers=nn.ModuleDict({k:Tower(int(dims[k]),outdims[k]) for k in self.BRANCHES})
|
| 29 |
+
self.branch_heads=nn.ModuleDict({k:nn.Linear(outdims[k],1) for k in self.BRANCHES})
|
| 30 |
+
total=sum(outdims.values()); self.pre_vib=nn.Sequential(nn.LayerNorm(total),nn.Linear(total,128),nn.GELU(),nn.Dropout(.15))
|
| 31 |
+
self.mu=nn.Linear(128,latent_dim); self.logvar=nn.Linear(128,latent_dim)
|
| 32 |
+
qdim=int(dims["quality"]); self.router=nn.Sequential(nn.LayerNorm(qdim),nn.Linear(qdim,48),nn.GELU(),nn.Linear(48,len(self.BRANCHES)))
|
| 33 |
+
self.anomaly_head=nn.Sequential(nn.LayerNorm(5),nn.Linear(5,24),nn.GELU(),nn.Linear(24,1))
|
| 34 |
+
self.global_head=nn.Sequential(nn.LayerNorm(latent_dim),nn.Linear(latent_dim,48),nn.GELU(),nn.Dropout(.12),nn.Linear(48,1))
|
| 35 |
+
self.mixer=nn.Linear(3,1)
|
| 36 |
+
with torch.no_grad():
|
| 37 |
+
self.mixer.weight.copy_(torch.tensor([[1.0,1.0,.8]])); self.mixer.bias.zero_()
|
| 38 |
+
self.ai_domain_head=nn.Sequential(nn.Linear(latent_dim,48),nn.GELU(),nn.Linear(48,num_ai_domains)) if num_ai_domains>1 else None
|
| 39 |
+
self.real_domain_head=nn.Sequential(nn.Linear(latent_dim,48),nn.GELU(),nn.Linear(48,num_real_domains)) if num_real_domains>1 else None
|
| 40 |
+
|
| 41 |
+
def _latent(self,h,sample:bool):
|
| 42 |
+
p=self.pre_vib(h); mu=self.mu(p); lv=torch.clamp(self.logvar(p),-6,4)
|
| 43 |
+
if sample and self.training: z=mu+torch.randn_like(mu)*torch.exp(.5*lv)
|
| 44 |
+
else: z=mu
|
| 45 |
+
kl=-.5*torch.mean(1+lv-mu.pow(2)-lv.exp())
|
| 46 |
+
return z,mu,lv,kl
|
| 47 |
+
|
| 48 |
+
def forward(self,features:dict,quality:torch.Tensor,anchors:torch.Tensor,luma:torch.Tensor,domain_lambda:float=0.0,sample_vib:bool=True):
|
| 49 |
+
parts={k:self.towers[k](features[k]) for k in self.BRANCHES}; h=torch.cat([parts[k] for k in self.BRANCHES],1); z,mu,lv,kl=self._latent(h,sample_vib)
|
| 50 |
+
branch_logits=torch.cat([self.branch_heads[k](parts[k]) for k in self.BRANCHES],1)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
route_quality=quality.clone()
|
| 54 |
+
if route_quality.shape[1]>=12: route_quality[:,10:12]=0.0
|
| 55 |
+
route_logits=self.router(route_quality)
|
| 56 |
+
|
| 57 |
+
darkness=torch.clamp((.25-luma)/.25,0,1).unsqueeze(1)
|
| 58 |
+
prior=torch.tensor([-.60,.50,.38,.30,.12],dtype=route_logits.dtype,device=route_logits.device).unsqueeze(0)
|
| 59 |
+
gates=F.softmax(route_logits+darkness*prior,dim=1); gated=(gates*branch_logits).sum(1,keepdim=True)
|
| 60 |
+
global_logit=self.global_head(z); anomaly_logit=self.anomaly_head(anchors)
|
| 61 |
+
|
| 62 |
+
components=torch.cat([global_logit,gated,anomaly_logit],1); logit=self.mixer(components).squeeze(1)
|
| 63 |
+
ai_domain=self.ai_domain_head(grad_reverse(z,domain_lambda)) if self.ai_domain_head is not None and domain_lambda>0 else None
|
| 64 |
+
real_domain=self.real_domain_head(grad_reverse(z,domain_lambda)) if self.real_domain_head is not None and domain_lambda>0 else None
|
| 65 |
+
return {"logit":logit,"branch_logits":branch_logits,"gates":gates,"components":components,"latent":mu,"kl":kl,"ai_domain_logits":ai_domain,"real_domain_logits":real_domain}
|
raven/onnx_export.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
import torch
|
| 4 |
+
import torch.nn as nn
|
| 5 |
+
from .model import RavenFusion
|
| 6 |
+
from .foundation import FoundationEncoder
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class FusionEnsembleWrapper(nn.Module):
|
| 10 |
+
KEYS=("semantic","camera","spectral","patch","specialist")
|
| 11 |
+
def __init__(self,models): super().__init__(); self.models=nn.ModuleList(models)
|
| 12 |
+
def forward(self,semantic,camera,spectral,patch,specialist,quality,anchors,luma):
|
| 13 |
+
f={"semantic":semantic,"camera":camera,"spectral":spectral,"patch":patch,"specialist":specialist}; outs=[m(f,quality,anchors,luma,0.0,False) for m in self.models]
|
| 14 |
+
logits=torch.stack([o["logit"] for o in outs],1); branches=torch.stack([o["branch_logits"] for o in outs],1); gates=torch.stack([o["gates"] for o in outs],1)
|
| 15 |
+
return logits.mean(1),logits.std(1,unbiased=False),branches.mean(1),gates.mean(1)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class FoundationWrapper(nn.Module):
|
| 19 |
+
def __init__(self,enc): super().__init__(); self.enc=enc
|
| 20 |
+
def forward(self,x): return self.enc(x)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def export_fusion(model_path:str,out_path:str):
|
| 24 |
+
ck=torch.load(model_path,map_location="cpu",weights_only=False); models=[]
|
| 25 |
+
for st in ck["ensemble_states"]:
|
| 26 |
+
m=RavenFusion(ck["dims"],len(ck.get("ai_source_map",{})),len(ck.get("real_source_map",{}))); m.load_state_dict(st); m.eval(); models.append(m)
|
| 27 |
+
w=FusionEnsembleWrapper(models).eval(); d=ck["dims"]; inputs=[torch.randn(1,d[k]) for k in ("semantic","camera","spectral","patch","specialist")]+[torch.randn(1,d["quality"]),torch.randn(1,5),torch.tensor([.3])]
|
| 28 |
+
Path(out_path).parent.mkdir(parents=True,exist_ok=True)
|
| 29 |
+
torch.onnx.export(w,tuple(inputs),out_path,input_names=["semantic","camera","spectral","patch","specialist","quality","anchors","luma"],output_names=["logit_mean","logit_std","branch_mean","gates_mean"],dynamic_axes={n:{0:"batch"} for n in ["semantic","camera","spectral","patch","specialist","quality","anchors","luma","logit_mean","logit_std","branch_mean","gates_mean"]},opset_version=18)
|
| 30 |
+
return out_path
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def export_foundation(model_path:str,out_path:str):
|
| 34 |
+
ck=torch.load(model_path,map_location="cpu",weights_only=False); enc=FoundationEncoder(ck["foundation_backend"],allow_fallback=False,fallback_backend=ck["foundation_backend"],pretrained=ck.get("foundation_state") is None).eval();
|
| 35 |
+
if ck.get("foundation_state") is not None: enc.model.load_state_dict(ck["foundation_state"]["state_dict"])
|
| 36 |
+
w=FoundationWrapper(enc).eval(); x=torch.randn(1,3,224,224)
|
| 37 |
+
Path(out_path).parent.mkdir(parents=True,exist_ok=True)
|
| 38 |
+
torch.onnx.export(w,x,out_path,input_names=["image"],output_names=["embedding"],dynamic_axes={"image":{0:"batch"},"embedding":{0:"batch"}},opset_version=18)
|
| 39 |
+
return out_path
|
raven/onnx_runtime.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
import numpy as np
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
def open_session(path: str, prefer_directml: bool = True):
|
| 6 |
+
try:
|
| 7 |
+
import onnxruntime as ort
|
| 8 |
+
except Exception as exc:
|
| 9 |
+
raise RuntimeError("Install requirements-directml.txt (or onnxruntime) first") from exc
|
| 10 |
+
|
| 11 |
+
available = ort.get_available_providers()
|
| 12 |
+
providers = []
|
| 13 |
+
opts = ort.SessionOptions()
|
| 14 |
+
opts.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
|
| 15 |
+
|
| 16 |
+
if prefer_directml and "DmlExecutionProvider" in available:
|
| 17 |
+
|
| 18 |
+
opts.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL
|
| 19 |
+
opts.enable_mem_pattern = False
|
| 20 |
+
providers.append("DmlExecutionProvider")
|
| 21 |
+
else:
|
| 22 |
+
|
| 23 |
+
opts.intra_op_num_threads = 6
|
| 24 |
+
opts.inter_op_num_threads = 1
|
| 25 |
+
providers.append("CPUExecutionProvider")
|
| 26 |
+
|
| 27 |
+
if "CPUExecutionProvider" not in providers:
|
| 28 |
+
providers.append("CPUExecutionProvider")
|
| 29 |
+
return ort.InferenceSession(path, sess_options=opts, providers=providers)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def run_foundation(session, image: np.ndarray) -> np.ndarray:
|
| 33 |
+
return session.run(["embedding"], {"image": np.asarray(image, np.float32)})[0]
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def run_fusion(session, inputs: dict):
|
| 37 |
+
feed = {k: np.asarray(v, np.float32) for k, v in inputs.items()}
|
| 38 |
+
o = session.run(None, feed)
|
| 39 |
+
return {"logit_mean": o[0], "logit_std": o[1], "branch_mean": o[2], "gates_mean": o[3]}
|
raven/parquet_io.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import hashlib
|
| 4 |
+
import io
|
| 5 |
+
import json
|
| 6 |
+
import shutil
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import Iterable
|
| 9 |
+
|
| 10 |
+
from PIL import Image
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
COMMON_IMAGE_COLUMNS = (
|
| 14 |
+
"image", "images", "jpg", "jpeg", "png", "webp", "bytes", "image_bytes"
|
| 15 |
+
)
|
| 16 |
+
COMMON_SOURCE_COLUMNS = (
|
| 17 |
+
"source", "generator", "model", "camera", "camera_model", "origin", "dataset"
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _require_pyarrow():
|
| 22 |
+
try:
|
| 23 |
+
import pyarrow.parquet as pq
|
| 24 |
+
return pq
|
| 25 |
+
except Exception as exc:
|
| 26 |
+
raise RuntimeError(
|
| 27 |
+
"Parquet support requires pyarrow. Run windows\\01_install_cpu.bat "
|
| 28 |
+
"or: python -m pip install pyarrow"
|
| 29 |
+
) from exc
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def discover_parquet_files(path: str | Path) -> list[Path]:
|
| 33 |
+
p = Path(path)
|
| 34 |
+
if not p.exists():
|
| 35 |
+
return []
|
| 36 |
+
if p.is_file() and p.suffix.lower() == ".parquet":
|
| 37 |
+
return [p.resolve()]
|
| 38 |
+
if p.is_dir():
|
| 39 |
+
return sorted(x.resolve() for x in p.rglob("*.parquet") if x.is_file())
|
| 40 |
+
return []
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _choose_column(names: list[str], requested: str | None, candidates: Iterable[str]) -> str | None:
|
| 44 |
+
if requested:
|
| 45 |
+
if requested not in names:
|
| 46 |
+
raise KeyError(f"Parquet column '{requested}' not found. Columns={names}")
|
| 47 |
+
return requested
|
| 48 |
+
lower = {n.lower(): n for n in names}
|
| 49 |
+
for c in candidates:
|
| 50 |
+
if c.lower() in lower:
|
| 51 |
+
return lower[c.lower()]
|
| 52 |
+
return None
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def inspect_parquet(parquet_path: str | Path) -> dict:
|
| 56 |
+
pq = _require_pyarrow()
|
| 57 |
+
pf = pq.ParquetFile(str(parquet_path))
|
| 58 |
+
names = list(pf.schema_arrow.names)
|
| 59 |
+
image_col = _choose_column(names, None, COMMON_IMAGE_COLUMNS)
|
| 60 |
+
source_col = _choose_column(names, None, COMMON_SOURCE_COLUMNS)
|
| 61 |
+
return {
|
| 62 |
+
"path": str(Path(parquet_path).resolve()),
|
| 63 |
+
"rows": int(pf.metadata.num_rows),
|
| 64 |
+
"row_groups": int(pf.metadata.num_row_groups),
|
| 65 |
+
"columns": names,
|
| 66 |
+
"auto_image_column": image_col,
|
| 67 |
+
"auto_source_column": source_col,
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def _cell_to_bytes(cell, parquet_parent: Path) -> bytes | None:
|
| 72 |
+
if cell is None:
|
| 73 |
+
return None
|
| 74 |
+
if isinstance(cell, memoryview):
|
| 75 |
+
return cell.tobytes()
|
| 76 |
+
if isinstance(cell, (bytes, bytearray)):
|
| 77 |
+
return bytes(cell)
|
| 78 |
+
if isinstance(cell, str):
|
| 79 |
+
p = Path(cell)
|
| 80 |
+
if not p.is_absolute():
|
| 81 |
+
p = parquet_parent / p
|
| 82 |
+
return p.read_bytes() if p.exists() and p.is_file() else None
|
| 83 |
+
if isinstance(cell, dict):
|
| 84 |
+
raw = cell.get("bytes")
|
| 85 |
+
if raw is not None:
|
| 86 |
+
if isinstance(raw, memoryview):
|
| 87 |
+
return raw.tobytes()
|
| 88 |
+
if isinstance(raw, (bytes, bytearray)):
|
| 89 |
+
return bytes(raw)
|
| 90 |
+
path_value = cell.get("path")
|
| 91 |
+
if path_value:
|
| 92 |
+
p = Path(str(path_value))
|
| 93 |
+
if not p.is_absolute():
|
| 94 |
+
p = parquet_parent / p
|
| 95 |
+
return p.read_bytes() if p.exists() and p.is_file() else None
|
| 96 |
+
return None
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def _ext_from_bytes(raw: bytes) -> str:
|
| 100 |
+
try:
|
| 101 |
+
with Image.open(io.BytesIO(raw)) as im:
|
| 102 |
+
fmt = (im.format or "").upper()
|
| 103 |
+
return {
|
| 104 |
+
"JPEG": ".jpg",
|
| 105 |
+
"JPG": ".jpg",
|
| 106 |
+
"PNG": ".png",
|
| 107 |
+
"WEBP": ".webp",
|
| 108 |
+
"BMP": ".bmp",
|
| 109 |
+
"TIFF": ".tiff",
|
| 110 |
+
"GIF": ".gif",
|
| 111 |
+
}.get(fmt, ".img")
|
| 112 |
+
except Exception:
|
| 113 |
+
return ".img"
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def _safe_text(value, fallback: str) -> str:
|
| 117 |
+
if value is None:
|
| 118 |
+
return fallback
|
| 119 |
+
s = str(value).strip()
|
| 120 |
+
if not s:
|
| 121 |
+
return fallback
|
| 122 |
+
return "".join(c if c.isalnum() or c in "-_.:" else "_" for c in s)[:120]
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def materialize_parquet(
|
| 126 |
+
parquet_path: str | Path,
|
| 127 |
+
cache_root: str | Path,
|
| 128 |
+
label: int,
|
| 129 |
+
default_source: str | None = None,
|
| 130 |
+
image_column: str | None = None,
|
| 131 |
+
source_column: str | None = None,
|
| 132 |
+
batch_size: int = 128,
|
| 133 |
+
) -> list[dict]:
|
| 134 |
+
|
| 135 |
+
pq = _require_pyarrow()
|
| 136 |
+
parquet_path = Path(parquet_path).resolve()
|
| 137 |
+
pf = pq.ParquetFile(str(parquet_path))
|
| 138 |
+
names = list(pf.schema_arrow.names)
|
| 139 |
+
image_col = _choose_column(names, image_column, COMMON_IMAGE_COLUMNS)
|
| 140 |
+
if image_col is None:
|
| 141 |
+
raise KeyError(
|
| 142 |
+
f"Could not auto-detect an image column in {parquet_path.name}. "
|
| 143 |
+
f"Columns={names}. Use --real-parquet-image-column/--ai-parquet-image-column."
|
| 144 |
+
)
|
| 145 |
+
src_col = _choose_column(names, source_column, COMMON_SOURCE_COLUMNS)
|
| 146 |
+
|
| 147 |
+
stat=parquet_path.stat()
|
| 148 |
+
cache_key=json.dumps({"path":str(parquet_path),"size":stat.st_size,"mtime_ns":stat.st_mtime_ns,"label":int(label),"default_source":default_source,"image_column":image_col,"source_column":src_col},sort_keys=True)
|
| 149 |
+
digest = hashlib.sha256(cache_key.encode("utf-8")).hexdigest()[:16]
|
| 150 |
+
out_dir = Path(cache_root).resolve() / digest
|
| 151 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 152 |
+
index_file = out_dir / "_index.jsonl"
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
if index_file.exists():
|
| 156 |
+
try:
|
| 157 |
+
cached = [json.loads(x) for x in index_file.read_text(encoding="utf-8").splitlines() if x.strip()]
|
| 158 |
+
if cached and all(Path(r["path"]).exists() for r in cached):
|
| 159 |
+
return cached
|
| 160 |
+
except Exception:
|
| 161 |
+
pass
|
| 162 |
+
|
| 163 |
+
columns = [image_col] + ([src_col] if src_col and src_col != image_col else [])
|
| 164 |
+
default_source = _safe_text(default_source, parquet_path.stem)
|
| 165 |
+
descriptors: list[dict] = []
|
| 166 |
+
global_row = 0
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
for batch in pf.iter_batches(batch_size=max(1, int(batch_size)), columns=columns):
|
| 170 |
+
rows = batch.to_pylist()
|
| 171 |
+
for row in rows:
|
| 172 |
+
raw = _cell_to_bytes(row.get(image_col), parquet_path.parent)
|
| 173 |
+
src = _safe_text(row.get(src_col) if src_col else None, default_source)
|
| 174 |
+
if raw is None:
|
| 175 |
+
global_row += 1
|
| 176 |
+
continue
|
| 177 |
+
ext = _ext_from_bytes(raw)
|
| 178 |
+
if ext == ".img":
|
| 179 |
+
|
| 180 |
+
try:
|
| 181 |
+
with Image.open(io.BytesIO(raw)) as im:
|
| 182 |
+
im = im.convert("RGB")
|
| 183 |
+
out_path = out_dir / f"{global_row:09d}.png"
|
| 184 |
+
if not out_path.exists():
|
| 185 |
+
im.save(out_path, "PNG")
|
| 186 |
+
except Exception:
|
| 187 |
+
global_row += 1
|
| 188 |
+
continue
|
| 189 |
+
else:
|
| 190 |
+
out_path = out_dir / f"{global_row:09d}{ext}"
|
| 191 |
+
if not out_path.exists() or out_path.stat().st_size != len(raw):
|
| 192 |
+
out_path.write_bytes(raw)
|
| 193 |
+
descriptors.append({
|
| 194 |
+
"path": str(out_path.resolve()),
|
| 195 |
+
"label": int(label),
|
| 196 |
+
"source_leaf": src,
|
| 197 |
+
"group": f"parquet:{parquet_path.name}:{global_row}",
|
| 198 |
+
"origin_kind": "parquet",
|
| 199 |
+
"origin_parquet": str(parquet_path),
|
| 200 |
+
"origin_row": int(global_row),
|
| 201 |
+
"parquet_image_column": image_col,
|
| 202 |
+
"parquet_source_column": src_col,
|
| 203 |
+
})
|
| 204 |
+
global_row += 1
|
| 205 |
+
|
| 206 |
+
index_file.write_text(
|
| 207 |
+
"\n".join(json.dumps(r, ensure_ascii=False) for r in descriptors) + ("\n" if descriptors else ""),
|
| 208 |
+
encoding="utf-8",
|
| 209 |
+
)
|
| 210 |
+
return descriptors
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def import_parquet(
|
| 214 |
+
parquet_path: str,
|
| 215 |
+
out_dir: str,
|
| 216 |
+
label: int,
|
| 217 |
+
source: str,
|
| 218 |
+
manifest_out: str,
|
| 219 |
+
image_column: str = "image",
|
| 220 |
+
):
|
| 221 |
+
|
| 222 |
+
from .utils import write_jsonl
|
| 223 |
+
|
| 224 |
+
rows = materialize_parquet(
|
| 225 |
+
parquet_path=parquet_path,
|
| 226 |
+
cache_root=out_dir,
|
| 227 |
+
label=label,
|
| 228 |
+
default_source=source,
|
| 229 |
+
image_column=image_column,
|
| 230 |
+
source_column=None,
|
| 231 |
+
)
|
| 232 |
+
manifest_rows = [
|
| 233 |
+
{"path": r["path"], "label": int(label), "source": source, "origin_parquet": r["origin_parquet"], "origin_row": r["origin_row"]}
|
| 234 |
+
for r in rows
|
| 235 |
+
]
|
| 236 |
+
write_jsonl(manifest_out, manifest_rows)
|
| 237 |
+
return {"count": len(rows), "out_dir": str(Path(out_dir).resolve()), "manifest": manifest_out}
|
raven/provenance.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
import json
|
| 3 |
+
import shutil
|
| 4 |
+
import subprocess
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def inspect_provenance(path: str) -> dict:
|
| 8 |
+
|
| 9 |
+
tool=shutil.which("c2patool")
|
| 10 |
+
if not tool: return {"available":False,"ai_provenance":False,"note":"c2patool not installed"}
|
| 11 |
+
try:
|
| 12 |
+
p=subprocess.run([tool,"--json",path],capture_output=True,text=True,timeout=12)
|
| 13 |
+
text=(p.stdout or "")+(p.stderr or ""); low=text.lower()
|
| 14 |
+
markers=["trainedalgorithmicmedia","trained algorithmic media","ai-generated","ai generated","openai","chatgpt"]
|
| 15 |
+
hit=next((m for m in markers if m in low),None)
|
| 16 |
+
return {"available":True,"ai_provenance":bool(hit),"marker":hit,"raw_present":bool(text.strip())}
|
| 17 |
+
except Exception as exc:
|
| 18 |
+
return {"available":True,"ai_provenance":False,"error":str(exc)}
|
raven/specialists.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import copy
|
| 4 |
+
from collections import defaultdict
|
| 5 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
import random
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
import torch
|
| 11 |
+
import torch.nn as nn
|
| 12 |
+
import torch.nn.functional as F
|
| 13 |
+
from torch.utils.data import Dataset, DataLoader
|
| 14 |
+
from tqdm import tqdm
|
| 15 |
+
|
| 16 |
+
from .forensic_features import load_rgb, spectral_map, camera_residual_map
|
| 17 |
+
from .utils import configure_cpu, seed_everything
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _make_maps(item):
|
| 21 |
+
r, map_size = item
|
| 22 |
+
im = load_rgb(r["path"])
|
| 23 |
+
return spectral_map(im, map_size), camera_residual_map(im, map_size)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class MapDataset(Dataset):
|
| 27 |
+
def __init__(self, rows: list[dict], map_size: int, precompute_workers: int = 0):
|
| 28 |
+
self.rows = rows
|
| 29 |
+
self.map_size = map_size
|
| 30 |
+
jobs = [(r, map_size) for r in rows]
|
| 31 |
+
spectral, camera = [], []
|
| 32 |
+
if precompute_workers > 1:
|
| 33 |
+
with ThreadPoolExecutor(max_workers=precompute_workers) as ex:
|
| 34 |
+
it = ex.map(_make_maps, jobs)
|
| 35 |
+
for s, c in tqdm(it, total=len(jobs), desc="precompute-real-maps", leave=False):
|
| 36 |
+
spectral.append(s)
|
| 37 |
+
camera.append(c)
|
| 38 |
+
else:
|
| 39 |
+
for j in tqdm(jobs, desc="precompute-real-maps", leave=False):
|
| 40 |
+
s, c = _make_maps(j)
|
| 41 |
+
spectral.append(s)
|
| 42 |
+
camera.append(c)
|
| 43 |
+
|
| 44 |
+
self.spectral = np.stack(spectral).astype(np.float16) if spectral else np.zeros((0, map_size, map_size), np.float16)
|
| 45 |
+
self.camera = np.stack(camera).astype(np.float16) if camera else np.zeros((0, map_size, map_size), np.float16)
|
| 46 |
+
|
| 47 |
+
def __len__(self):
|
| 48 |
+
return len(self.rows)
|
| 49 |
+
|
| 50 |
+
def __getitem__(self, i):
|
| 51 |
+
return torch.from_numpy(self.spectral[i][None]), torch.from_numpy(self.camera[i][None])
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class ConvAutoencoder(nn.Module):
|
| 55 |
+
def __init__(self, latent_dim: int = 48):
|
| 56 |
+
super().__init__()
|
| 57 |
+
self.enc = nn.Sequential(
|
| 58 |
+
nn.Conv2d(1, 16, 3, 2, 1), nn.GELU(),
|
| 59 |
+
nn.Conv2d(16, 32, 3, 2, 1), nn.GELU(),
|
| 60 |
+
nn.Conv2d(32, 48, 3, 2, 1), nn.GELU(),
|
| 61 |
+
nn.Conv2d(48, latent_dim, 3, 2, 1), nn.GELU(),
|
| 62 |
+
)
|
| 63 |
+
self.dec = nn.Sequential(
|
| 64 |
+
nn.ConvTranspose2d(latent_dim, 48, 4, 2, 1), nn.GELU(),
|
| 65 |
+
nn.ConvTranspose2d(48, 32, 4, 2, 1), nn.GELU(),
|
| 66 |
+
nn.ConvTranspose2d(32, 16, 4, 2, 1), nn.GELU(),
|
| 67 |
+
nn.ConvTranspose2d(16, 1, 4, 2, 1),
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
def forward(self, x):
|
| 71 |
+
return self.dec(self.enc(x))
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _mask_blocks(x: torch.Tensor, fraction: float) -> torch.Tensor:
|
| 75 |
+
if fraction <= 0:
|
| 76 |
+
return x
|
| 77 |
+
b, _, h, w = x.shape
|
| 78 |
+
mask = torch.ones_like(x)
|
| 79 |
+
block = max(4, h // 8)
|
| 80 |
+
n = max(1, int((h * w * fraction) / (block * block)))
|
| 81 |
+
for bi in range(b):
|
| 82 |
+
for _ in range(n):
|
| 83 |
+
y = random.randint(0, max(0, h - block))
|
| 84 |
+
xx = random.randint(0, max(0, w - block))
|
| 85 |
+
mask[bi, :, y:y + block, xx:xx + block] = 0
|
| 86 |
+
return x * mask
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def _stratified_cap_real(rows: list[dict], limit: int, seed: int) -> list[dict]:
|
| 90 |
+
|
| 91 |
+
if limit <= 0 or len(rows) <= limit:
|
| 92 |
+
return list(rows)
|
| 93 |
+
groups = defaultdict(list)
|
| 94 |
+
for r in rows:
|
| 95 |
+
key = (str(r.get("light_bucket", "unknown")), str(r.get("source", "real")))
|
| 96 |
+
groups[key].append(r)
|
| 97 |
+
rng = random.Random(seed)
|
| 98 |
+
for v in groups.values():
|
| 99 |
+
rng.shuffle(v)
|
| 100 |
+
keys = list(groups)
|
| 101 |
+
rng.shuffle(keys)
|
| 102 |
+
out = []
|
| 103 |
+
cursor = {k: 0 for k in keys}
|
| 104 |
+
while len(out) < limit:
|
| 105 |
+
added = False
|
| 106 |
+
for k in keys:
|
| 107 |
+
i = cursor[k]
|
| 108 |
+
if i < len(groups[k]):
|
| 109 |
+
out.append(groups[k][i])
|
| 110 |
+
cursor[k] = i + 1
|
| 111 |
+
added = True
|
| 112 |
+
if len(out) >= limit:
|
| 113 |
+
break
|
| 114 |
+
if not added:
|
| 115 |
+
break
|
| 116 |
+
return out
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def train_real_specialists(rows: list[dict], cfg: dict, out_path: str) -> dict:
|
| 120 |
+
hc = cfg["hardware"]
|
| 121 |
+
rcfg = cfg["real_specialists"]
|
| 122 |
+
seed = int(cfg["training"].get("seed", 1337))
|
| 123 |
+
configure_cpu(hc.get("torch_threads", 6), hc.get("interop_threads", 1))
|
| 124 |
+
seed_everything(seed)
|
| 125 |
+
|
| 126 |
+
real = [r for r in rows if int(r.get("label", -1)) == 0 and r.get("split") == "train"]
|
| 127 |
+
real = _stratified_cap_real(real, int(rcfg.get("max_real_images", 5000)), seed)
|
| 128 |
+
random.Random(seed).shuffle(real)
|
| 129 |
+
if len(real) < 50:
|
| 130 |
+
raise ValueError("Need at least 50 REAL training images for real-only specialists")
|
| 131 |
+
|
| 132 |
+
nval = max(20, min(512, int(len(real) * .12)))
|
| 133 |
+
val = real[:nval]
|
| 134 |
+
train = real[nval:]
|
| 135 |
+
msize = int(rcfg.get("map_size", 64))
|
| 136 |
+
pre_workers = max(0, int(rcfg.get("precompute_workers", hc.get("preprocess_workers", 2))))
|
| 137 |
+
ds = MapDataset(train, msize, pre_workers)
|
| 138 |
+
vds = MapDataset(val, msize, pre_workers)
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
loader = DataLoader(ds, batch_size=int(rcfg.get("batch_size", 64)), shuffle=True, num_workers=0)
|
| 142 |
+
vloader = DataLoader(vds, batch_size=int(rcfg.get("batch_size", 64)), shuffle=False, num_workers=0)
|
| 143 |
+
|
| 144 |
+
device = torch.device("cpu")
|
| 145 |
+
spec = ConvAutoencoder(int(rcfg.get("latent_dim", 40))).to(device)
|
| 146 |
+
cam = ConvAutoencoder(int(rcfg.get("latent_dim", 40))).to(device)
|
| 147 |
+
params = list(spec.parameters()) + list(cam.parameters())
|
| 148 |
+
opt = torch.optim.AdamW(
|
| 149 |
+
params,
|
| 150 |
+
lr=float(rcfg.get("lr", .0018)),
|
| 151 |
+
weight_decay=float(rcfg.get("weight_decay", 1e-4)),
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
best = float("inf")
|
| 155 |
+
best_state = None
|
| 156 |
+
stale = 0
|
| 157 |
+
patience = int(rcfg.get("early_stop_patience", 3))
|
| 158 |
+
epochs = int(rcfg.get("epochs", 10))
|
| 159 |
+
frac = float(rcfg.get("mask_fraction", .25))
|
| 160 |
+
|
| 161 |
+
for ep in range(epochs):
|
| 162 |
+
spec.train()
|
| 163 |
+
cam.train()
|
| 164 |
+
total = 0.0
|
| 165 |
+
count = 0
|
| 166 |
+
bar = tqdm(loader, desc=f"real-specialists {ep + 1}/{epochs}")
|
| 167 |
+
for s, c in bar:
|
| 168 |
+
s = s.to(device).float()
|
| 169 |
+
c = c.to(device).float()
|
| 170 |
+
opt.zero_grad(set_to_none=True)
|
| 171 |
+
sin = _mask_blocks(s, frac)
|
| 172 |
+
cin = c + torch.randn_like(c) * float(rcfg.get("camera_noise_std", .015))
|
| 173 |
+
ps = spec(sin)
|
| 174 |
+
pc = cam(cin)
|
| 175 |
+
loss = F.smooth_l1_loss(ps, s) + F.smooth_l1_loss(pc, c)
|
| 176 |
+
loss.backward()
|
| 177 |
+
torch.nn.utils.clip_grad_norm_(params, 3.0)
|
| 178 |
+
opt.step()
|
| 179 |
+
total += float(loss.item())
|
| 180 |
+
count += 1
|
| 181 |
+
bar.set_postfix(loss=f"{total / count:.4f}")
|
| 182 |
+
|
| 183 |
+
spec.eval()
|
| 184 |
+
cam.eval()
|
| 185 |
+
vals = []
|
| 186 |
+
with torch.inference_mode():
|
| 187 |
+
for s, c in vloader:
|
| 188 |
+
s = s.float()
|
| 189 |
+
c = c.float()
|
| 190 |
+
ps = spec(s)
|
| 191 |
+
pc = cam(c)
|
| 192 |
+
vals.append(float((F.l1_loss(ps, s) + F.l1_loss(pc, c)).item()))
|
| 193 |
+
score = float(np.mean(vals)) if vals else total / max(1, count)
|
| 194 |
+
print(f"[specialists] validation={score:.6f}")
|
| 195 |
+
if score < best - 1e-5:
|
| 196 |
+
best = score
|
| 197 |
+
best_state = {"spectral": copy.deepcopy(spec.state_dict()), "camera": copy.deepcopy(cam.state_dict())}
|
| 198 |
+
stale = 0
|
| 199 |
+
else:
|
| 200 |
+
stale += 1
|
| 201 |
+
if stale >= patience:
|
| 202 |
+
break
|
| 203 |
+
|
| 204 |
+
if best_state:
|
| 205 |
+
spec.load_state_dict(best_state["spectral"])
|
| 206 |
+
cam.load_state_dict(best_state["camera"])
|
| 207 |
+
Path(out_path).parent.mkdir(parents=True, exist_ok=True)
|
| 208 |
+
torch.save({
|
| 209 |
+
"version": 4,
|
| 210 |
+
"map_size": msize,
|
| 211 |
+
"latent_dim": int(rcfg.get("latent_dim", 40)),
|
| 212 |
+
"spectral_state": spec.state_dict(),
|
| 213 |
+
"camera_state": cam.state_dict(),
|
| 214 |
+
"validation_loss": best,
|
| 215 |
+
"capped_real_images": len(real),
|
| 216 |
+
}, out_path)
|
| 217 |
+
print(f"[saved] {out_path}")
|
| 218 |
+
return {"validation_loss": best, "real_train": len(train), "real_val": len(val), "capped_real_images": len(real)}
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def load_specialists(path: str, device="cpu"):
|
| 222 |
+
ck = torch.load(path, map_location=device, weights_only=False)
|
| 223 |
+
latent = int(ck.get("latent_dim", 48))
|
| 224 |
+
s = ConvAutoencoder(latent).to(device)
|
| 225 |
+
c = ConvAutoencoder(latent).to(device)
|
| 226 |
+
s.load_state_dict(ck["spectral_state"])
|
| 227 |
+
c.load_state_dict(ck["camera_state"])
|
| 228 |
+
s.eval()
|
| 229 |
+
c.eval()
|
| 230 |
+
return s, c, ck
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
@torch.inference_mode()
|
| 234 |
+
def specialist_scores(im, specialists_path: str, cache_obj=None) -> np.ndarray:
|
| 235 |
+
if cache_obj is None:
|
| 236 |
+
spec, cam, ck = load_specialists(specialists_path, "cpu")
|
| 237 |
+
else:
|
| 238 |
+
spec, cam, ck = cache_obj
|
| 239 |
+
size = int(ck["map_size"])
|
| 240 |
+
sm = torch.from_numpy(spectral_map(im, size)[None, None]).float()
|
| 241 |
+
cm = torch.from_numpy(camera_residual_map(im, size)[None, None]).float()
|
| 242 |
+
ps = spec(sm)
|
| 243 |
+
pc = cam(cm)
|
| 244 |
+
|
| 245 |
+
def metrics(pred, target):
|
| 246 |
+
err = torch.abs(pred - target)
|
| 247 |
+
mse = torch.mean((pred - target) ** 2)
|
| 248 |
+
mae = torch.mean(err)
|
| 249 |
+
cos = F.cosine_similarity(pred.flatten(1), target.flatten(1), dim=1).mean()
|
| 250 |
+
q90 = torch.quantile(err.flatten(), .90)
|
| 251 |
+
return [float(mae), float(mse), float(1 - cos), float(q90)]
|
| 252 |
+
|
| 253 |
+
return np.asarray(metrics(ps, sm) + metrics(pc, cm), dtype=np.float32)
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
def load_specialists_checkpoint(ck: dict, device="cpu"):
|
| 257 |
+
latent = int(ck.get("latent_dim", 48))
|
| 258 |
+
s = ConvAutoencoder(latent).to(device)
|
| 259 |
+
c = ConvAutoencoder(latent).to(device)
|
| 260 |
+
s.load_state_dict(ck["spectral_state"])
|
| 261 |
+
c.load_state_dict(ck["camera_state"])
|
| 262 |
+
s.eval()
|
| 263 |
+
c.eval()
|
| 264 |
+
return s, c, ck
|
raven/training.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
import copy,json,math
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
import numpy as np
|
| 5 |
+
import torch
|
| 6 |
+
import torch.nn.functional as F
|
| 7 |
+
from torch.utils.data import DataLoader, WeightedRandomSampler
|
| 8 |
+
from tqdm import tqdm
|
| 9 |
+
from sklearn.metrics import roc_auc_score
|
| 10 |
+
from .cache import load_cache
|
| 11 |
+
from .anchors import AnchorSet
|
| 12 |
+
from .dataset import FeatureNormalizer,CachedFeatureDataset,make_source_maps,balanced_sampler,BRANCH_KEYS
|
| 13 |
+
from .model import RavenFusion
|
| 14 |
+
from .calibration import MetaCalibrator,meta_features,select_thresholds
|
| 15 |
+
from .metrics import selective_metrics
|
| 16 |
+
from .utils import configure_cpu,seed_everything
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _dims(cache): return {**{k:int(cache[k].shape[1]) for k in BRANCH_KEYS},"quality":int(cache["quality"].shape[1])}
|
| 20 |
+
|
| 21 |
+
def _features_from_batch(batch,device,noise_sem=0.0,noise_for=0.0,branch_dropout:float=0.0):
|
| 22 |
+
out={}
|
| 23 |
+
for k in BRANCH_KEYS:
|
| 24 |
+
x=batch[k].to(device).float(); ns=noise_sem if k=="semantic" else noise_for
|
| 25 |
+
if ns>0: x=x+torch.randn_like(x)*ns
|
| 26 |
+
out[k]=x
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
if branch_dropout>0 and len(out.get("semantic",[])):
|
| 30 |
+
b=out["semantic"].shape[0]; candidates=[k for k in BRANCH_KEYS if k!="semantic"]
|
| 31 |
+
active=torch.rand(b,device=device)<float(branch_dropout)
|
| 32 |
+
which=torch.randint(0,len(candidates),(b,),device=device)
|
| 33 |
+
for ci,k in enumerate(candidates):
|
| 34 |
+
mask=active & (which==ci)
|
| 35 |
+
if mask.any(): out[k]=out[k].clone(); out[k][mask]=0
|
| 36 |
+
return out
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _binary_metric_loss(z:torch.Tensor,y:torch.Tensor)->torch.Tensor:
|
| 40 |
+
|
| 41 |
+
y=(y>=.5)
|
| 42 |
+
if not y.any() or (~y).sum()==0:
|
| 43 |
+
return z.new_tensor(0.0)
|
| 44 |
+
zn=F.normalize(z,dim=1)
|
| 45 |
+
c0=F.normalize(zn[~y].mean(0,keepdim=True),dim=1)
|
| 46 |
+
c1=F.normalize(zn[y].mean(0,keepdim=True),dim=1)
|
| 47 |
+
within=(1-(zn[~y]*c0).sum(1)).mean()+(1-(zn[y]*c1).sum(1)).mean()
|
| 48 |
+
between=F.relu(F.cosine_similarity(c0,c1,dim=1).mean()-0.15)
|
| 49 |
+
return .5*within+.5*between
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _hard_replay_sampler(ds, path_scores:dict[str,float], boost:float, seed:int):
|
| 53 |
+
labels=[]; sources=[]; paths=[]
|
| 54 |
+
for j in ds.indices:
|
| 55 |
+
labels.append(int(ds.cache["label"][j])); sources.append(str(ds.cache["source"][j])); paths.append(str(ds.cache["path"][j]))
|
| 56 |
+
weights=np.zeros(len(labels),dtype=np.float64)
|
| 57 |
+
for cls in (0,1):
|
| 58 |
+
ids=[i for i,v in enumerate(labels) if v==cls]
|
| 59 |
+
by={}
|
| 60 |
+
for i in ids: by.setdefault(sources[i],[]).append(i)
|
| 61 |
+
for inds in by.values():
|
| 62 |
+
base=1.0/(2.0*max(1,len(by))*max(1,len(inds)))
|
| 63 |
+
for i in inds: weights[i]=base*(1.0+float(boost)*float(path_scores.get(paths[i],0.0)))
|
| 64 |
+
sm=weights[ids].sum()
|
| 65 |
+
if sm>0: weights[ids]*=.5/sm
|
| 66 |
+
g=torch.Generator(); g.manual_seed(int(seed))
|
| 67 |
+
return WeightedRandomSampler(torch.as_tensor(weights,dtype=torch.double),num_samples=len(ds),replacement=True,generator=g)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _balanced_bce(logits, targets, raw_labels):
|
| 71 |
+
|
| 72 |
+
losses=F.binary_cross_entropy_with_logits(logits,targets,reduction="none")
|
| 73 |
+
raw_labels=raw_labels.reshape(-1)
|
| 74 |
+
pieces=[]
|
| 75 |
+
for cls in (0,1):
|
| 76 |
+
m=(raw_labels==cls)
|
| 77 |
+
if m.any(): pieces.append(losses[m].mean())
|
| 78 |
+
return torch.stack(pieces).mean() if pieces else losses.mean()
|
| 79 |
+
|
| 80 |
+
@torch.inference_mode()
|
| 81 |
+
def _collect_one(model,loader,device):
|
| 82 |
+
model.eval(); lo=[]; br=[]; ga=[]; labels=[]; idx=[]; sources=[]; paths=[]
|
| 83 |
+
for b in loader:
|
| 84 |
+
out=model(_features_from_batch(b,device),b["quality"].to(device).float(),b["anchors"].to(device).float(),b["luma"].to(device).float(),0.0,False)
|
| 85 |
+
lo.append(out["logit"].cpu().numpy()); br.append(out["branch_logits"].cpu().numpy()); ga.append(out["gates"].cpu().numpy()); labels.append(b["label"].numpy()); idx.append(b["index"].numpy()); sources.extend(b["source"]); paths.extend(b["path"])
|
| 86 |
+
return {"logit":np.concatenate(lo),"branch":np.concatenate(br),"gates":np.concatenate(ga),"labels":np.concatenate(labels).astype(int),"indices":np.concatenate(idx).astype(int),"sources":sources,"paths":paths}
|
| 87 |
+
|
| 88 |
+
@torch.inference_mode()
|
| 89 |
+
def collect_ensemble(models,loader,device):
|
| 90 |
+
per=[_collect_one(m,loader,device) for m in models]; logits=np.stack([x["logit"] for x in per],1); branches=np.stack([x["branch"] for x in per],1); gates=np.stack([x["gates"] for x in per],1)
|
| 91 |
+
return {"logit_mean":logits.mean(1),"logit_std":logits.std(1),"branch_mean":branches.mean(1),"gates_mean":gates.mean(1),"labels":per[0]["labels"],"indices":per[0]["indices"],"sources":per[0]["sources"],"paths":per[0]["paths"]}
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def _refine_hard(model,ds,vloader,device,tc,member_id:int):
|
| 95 |
+
epochs=int(tc.get("hard_refine_epochs",0))
|
| 96 |
+
if epochs<=0: return model
|
| 97 |
+
|
| 98 |
+
orig_idx=np.asarray([j for j in ds.indices if int(ds.cache["view_id"][j])==0],dtype=np.int64)
|
| 99 |
+
if len(orig_idx)<32: return model
|
| 100 |
+
ods=CachedFeatureDataset(ds.cache,orig_idx,ds.norm,ds.anchor_scores,ds.ai_source_map,ds.real_source_map)
|
| 101 |
+
ol=DataLoader(ods,batch_size=256,shuffle=False,num_workers=0)
|
| 102 |
+
pred=_collect_one(model,ol,device); probs=1/(1+np.exp(-np.clip(pred["logit"],-30,30)))
|
| 103 |
+
|
| 104 |
+
err=np.abs(probs-pred["labels"]); uncertainty=1.0-np.abs(probs-.5)*2.0; hardness=np.clip(.70*err+.30*uncertainty,0,1)
|
| 105 |
+
path_scores={p:float(h) for p,h in zip(pred["paths"],hardness)}
|
| 106 |
+
sampler=_hard_replay_sampler(ds,path_scores,float(tc.get("hard_replay_boost",4.0)),int(tc.get("seed",1337))+7000+member_id)
|
| 107 |
+
loader=DataLoader(ds,batch_size=int(tc.get("batch_size",256)),sampler=sampler,num_workers=0,drop_last=False)
|
| 108 |
+
opt=torch.optim.AdamW(model.parameters(),lr=float(tc.get("lr",.0014))*float(tc.get("hard_refine_lr_multiplier",.22)),weight_decay=float(tc.get("weight_decay",.01)))
|
| 109 |
+
best_pred=_collect_one(model,vloader,device); best=float(roc_auc_score(best_pred["labels"],best_pred["logit"])) if len(np.unique(best_pred["labels"]))==2 else 0.0
|
| 110 |
+
best_state=copy.deepcopy(model.state_dict()); stale=0
|
| 111 |
+
for ep in range(epochs):
|
| 112 |
+
model.train()
|
| 113 |
+
for b in loader:
|
| 114 |
+
y=b["label"].to(device).float(); smooth=float(tc.get("label_smoothing",.02)); ys=y*(1-smooth)+.5*smooth
|
| 115 |
+
f=_features_from_batch(b,device,float(tc.get("feature_noise_semantic",.012))*.6,float(tc.get("feature_noise_forensic",.020))*.6,float(tc.get("branch_dropout",.12))*.5)
|
| 116 |
+
q=b["quality"].to(device).float(); a=b["anchors"].to(device).float(); l=b["luma"].to(device).float(); opt.zero_grad(set_to_none=True)
|
| 117 |
+
o=model(f,q,a,l,0.0,True); loss=_balanced_bce(o["logit"],ys,y)+float(tc.get("branch_loss_weight",.12))*.5*_balanced_bce(o["branch_logits"].reshape(-1),ys[:,None].expand_as(o["branch_logits"]).reshape(-1),y[:,None].expand_as(o["branch_logits"]).reshape(-1))+float(tc.get("contrastive_weight",.028))*_binary_metric_loss(o["latent"],y)+float(tc.get("vib_beta",.0015))*o["kl"]
|
| 118 |
+
loss.backward(); torch.nn.utils.clip_grad_norm_(model.parameters(),3.0); opt.step()
|
| 119 |
+
vp=_collect_one(model,vloader,device); auc=float(roc_auc_score(vp["labels"],vp["logit"])) if len(np.unique(vp["labels"]))==2 else 0.0
|
| 120 |
+
if auc>best+5e-5: best=auc; best_state=copy.deepcopy(model.state_dict()); stale=0
|
| 121 |
+
else:
|
| 122 |
+
stale+=1
|
| 123 |
+
if stale>=2: break
|
| 124 |
+
model.load_state_dict(best_state)
|
| 125 |
+
print(f"[member {member_id+1}] hard-replay refined AUROC={best:.5f}")
|
| 126 |
+
return model
|
| 127 |
+
|
| 128 |
+
def _train_member(member_id,cache,base_train_mask,val_mask,normalizer,anchor_scores,ai_map,real_map,dims,cfg,excluded_sources):
|
| 129 |
+
tc=cfg["training"]; hc=cfg["hardware"]; device=torch.device("cpu")
|
| 130 |
+
mask=base_train_mask.copy()
|
| 131 |
+
if excluded_sources:
|
| 132 |
+
mask &= ~np.isin(cache["source"],list(excluded_sources))
|
| 133 |
+
train_idx=np.where(mask)[0]
|
| 134 |
+
|
| 135 |
+
pseudo=(base_train_mask & np.isin(cache["source"],list(excluded_sources)) & (cache["view_id"]==0)) if excluded_sources else np.zeros_like(base_train_mask)
|
| 136 |
+
member_val_idx=np.where(val_mask|pseudo)[0]
|
| 137 |
+
ds=CachedFeatureDataset(cache,train_idx,normalizer,anchor_scores,ai_map,real_map); vds=CachedFeatureDataset(cache,member_val_idx,normalizer,anchor_scores,ai_map,real_map)
|
| 138 |
+
workers=0
|
| 139 |
+
loader=DataLoader(ds,batch_size=int(tc.get("batch_size",64)),sampler=balanced_sampler(ds,float(tc.get("max_minority_oversample_factor",2.0)),int(tc.get("seed",1337))+member_id*101),num_workers=workers,drop_last=False)
|
| 140 |
+
vloader=DataLoader(vds,batch_size=256,shuffle=False,num_workers=0)
|
| 141 |
+
seed=int(tc.get("seed",1337))+member_id*101; seed_everything(seed)
|
| 142 |
+
model=RavenFusion(dims,len(ai_map),len(real_map)).to(device); opt=torch.optim.AdamW(model.parameters(),lr=float(tc.get("lr",.0012)),weight_decay=float(tc.get("weight_decay",.01)))
|
| 143 |
+
sched=torch.optim.lr_scheduler.CosineAnnealingLR(opt,T_max=max(1,int(tc.get("epochs",80))))
|
| 144 |
+
best=-1.0; state=None; stale=0; patience=int(tc.get("early_stop_patience",10)); epochs=int(tc.get("epochs",80)); total_steps=max(1,epochs*len(loader)); step=0
|
| 145 |
+
for ep in range(epochs):
|
| 146 |
+
model.train(); run=0.0; n=0
|
| 147 |
+
bar=tqdm(loader,desc=f"fusion m{member_id+1} {ep+1}/{epochs}",leave=False)
|
| 148 |
+
for b in bar:
|
| 149 |
+
y=b["label"].to(device).float(); smooth=float(tc.get("label_smoothing",.02)); ys=y*(1-smooth)+.5*smooth
|
| 150 |
+
dl=2/(1+math.exp(-10*min(1.0,step/total_steps)))-1
|
| 151 |
+
f1=_features_from_batch(b,device,float(tc.get("feature_noise_semantic",.012)),float(tc.get("feature_noise_forensic",.020)),float(tc.get("branch_dropout",.12)))
|
| 152 |
+
f2=_features_from_batch(b,device,float(tc.get("feature_noise_semantic",.012)),float(tc.get("feature_noise_forensic",.020)),float(tc.get("branch_dropout",.12)))
|
| 153 |
+
q=b["quality"].to(device).float(); a=b["anchors"].to(device).float(); l=b["luma"].to(device).float(); opt.zero_grad(set_to_none=True)
|
| 154 |
+
o1=model(f1,q,a,l,dl,True); o2=model(f2,q,a,l,0.0,True)
|
| 155 |
+
main=_balanced_bce(o1["logit"],ys,y)+.5*_balanced_bce(o2["logit"],ys,y)
|
| 156 |
+
bt=ys[:,None].expand_as(o1["branch_logits"]); raw_bt=y[:,None].expand_as(o1["branch_logits"]); branch=_balanced_bce(o1["branch_logits"].reshape(-1),bt.reshape(-1),raw_bt.reshape(-1))
|
| 157 |
+
consistency=F.mse_loss(torch.sigmoid(o1["logit"]),torch.sigmoid(o2["logit"]))
|
| 158 |
+
adv=torch.tensor(0.0,device=device); ai_d=b["ai_domain"].to(device); vr=ai_d>=0
|
| 159 |
+
if o1["ai_domain_logits"] is not None and vr.any(): adv=adv+float(tc.get("adversarial_weight_ai",.035))*F.cross_entropy(o1["ai_domain_logits"][vr],ai_d[vr])
|
| 160 |
+
re_d=b["real_domain"].to(device); vr2=re_d>=0
|
| 161 |
+
if o1["real_domain_logits"] is not None and vr2.any(): adv=adv+float(tc.get("adversarial_weight_real",.02))*F.cross_entropy(o1["real_domain_logits"][vr2],re_d[vr2])
|
| 162 |
+
metric=_binary_metric_loss(o1["latent"],y)
|
| 163 |
+
loss=main+float(tc.get("branch_loss_weight",.12))*branch+float(tc.get("consistency_weight",.08))*consistency+float(tc.get("contrastive_weight",.028))*metric+float(tc.get("vib_beta",.0015))*o1["kl"]+adv
|
| 164 |
+
loss.backward(); torch.nn.utils.clip_grad_norm_(model.parameters(),4.0); opt.step(); run+=float(loss.item()); n+=1; step+=1; bar.set_postfix(loss=f"{run/n:.4f}")
|
| 165 |
+
sched.step(); pred=_collect_one(model,vloader,device); auc=float(roc_auc_score(pred["labels"],pred["logit"])) if len(np.unique(pred["labels"]))==2 else 0.0
|
| 166 |
+
if auc>best+1e-4: best=auc; state=copy.deepcopy(model.state_dict()); stale=0
|
| 167 |
+
else:
|
| 168 |
+
stale+=1
|
| 169 |
+
if stale>=patience: break
|
| 170 |
+
if state is not None: model.load_state_dict(state)
|
| 171 |
+
model=_refine_hard(model,ds,vloader,device,tc,member_id)
|
| 172 |
+
final_pred=_collect_one(model,vloader,device); final_auc=float(roc_auc_score(final_pred["labels"],final_pred["logit"])) if len(np.unique(final_pred["labels"]))==2 else best
|
| 173 |
+
print(f"[member {member_id+1}] best pseudo-unseen/val AUROC={final_auc:.5f}; excluded={sorted(excluded_sources)}")
|
| 174 |
+
return model,final_auc
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def train_fusion(cache_path:str,cfg:dict,out_path:str):
|
| 178 |
+
hc=cfg["hardware"]; configure_cpu(hc.get("torch_threads",6),hc.get("interop_threads",1)); cache=load_cache(cache_path); dims=_dims(cache)
|
| 179 |
+
train_mask=cache["split"]=="train"; val_mask=(cache["split"]=="val")&(cache["view_id"]==0)
|
| 180 |
+
if train_mask.sum()<200 or val_mask.sum()<40: raise ValueError("Feature cache needs sufficient train/val samples. Build manifest with ~15% validation.")
|
| 181 |
+
normalizer=FeatureNormalizer.fit(cache,train_mask); anchors=AnchorSet.fit(cache,cfg); anchor_scores=anchors.score(cache); ai_map,real_map=make_source_maps(cache,train_mask)
|
| 182 |
+
ai_sources=sorted(ai_map); members=[]; member_info=[]; nmem=int(cfg["training"].get("ensemble_members",5)); drop=int(cfg["training"].get("source_dropout_per_member",1))
|
| 183 |
+
for m in range(nmem):
|
| 184 |
+
excluded=set()
|
| 185 |
+
if drop>0 and len(ai_sources)>=4:
|
| 186 |
+
for j in range(drop): excluded.add(ai_sources[(m*drop+j)%len(ai_sources)])
|
| 187 |
+
model,auc=_train_member(m,cache,train_mask,val_mask,normalizer,anchor_scores,ai_map,real_map,dims,cfg,excluded); members.append(model); member_info.append({"best_auc":auc,"excluded_ai_sources":sorted(excluded)})
|
| 188 |
+
val_idx=np.where(val_mask)[0]; vds=CachedFeatureDataset(cache,val_idx,normalizer,anchor_scores,ai_map,real_map); vloader=DataLoader(vds,batch_size=256,shuffle=False,num_workers=0)
|
| 189 |
+
ens=collect_ensemble(members,vloader,torch.device("cpu")); ii=ens["indices"]
|
| 190 |
+
mx=meta_features(ens["logit_mean"],ens["logit_std"],ens["branch_mean"],ens["gates_mean"],anchor_scores[ii],cache["specialist"][ii],cache["quality"][ii]); calibrator=MetaCalibrator.fit(mx,ens["labels"],bootstraps=int(cfg["calibration"].get("bootstrap_members",15)),seed=int(cfg["training"].get("seed",1337))); cstats=calibrator.predict_stats(mx); probs=cstats["mean"]
|
| 191 |
+
rthr,athr=select_thresholds(ens["labels"],probs,cfg["calibration"]); metrics=selective_metrics(ens["labels"],probs,rthr,athr,cstats["lower"],cstats["upper"])
|
| 192 |
+
meta_path=Path(cache_path).with_suffix(Path(cache_path).suffix+".meta.json"); cache_meta=json.loads(meta_path.read_text(encoding="utf-8")) if meta_path.exists() else {}
|
| 193 |
+
specialist_ck=None
|
| 194 |
+
if cache_meta.get("specialists") and Path(cache_meta["specialists"]).exists(): specialist_ck=torch.load(cache_meta["specialists"],map_location="cpu",weights_only=False)
|
| 195 |
+
foundation_state=None
|
| 196 |
+
if cache_meta.get("foundation_state") and Path(cache_meta["foundation_state"]).exists(): foundation_state=torch.load(cache_meta["foundation_state"],map_location="cpu",weights_only=False)
|
| 197 |
+
checkpoint={"version":5,"name":"RAVEN-F2K-BUDGET-MAX","config":cfg,"dims":dims,"foundation_backend":cache_meta.get("foundation_actual",cfg["foundation"].get("backend","dinov2_vits14")),"foundation_dim":cache_meta.get("foundation_dim",dims["semantic"]),"foundation_state":foundation_state,"normalizer":normalizer.to_dict(),"anchors":anchors.to_dict(),"ai_source_map":ai_map,"real_source_map":real_map,"ensemble_states":[m.state_dict() for m in members],"members":member_info,"calibrator":calibrator.to_dict(),"real_threshold":rthr,"ai_threshold":athr,"validation_metrics":metrics,"specialists_checkpoint":specialist_ck}
|
| 198 |
+
p=Path(out_path); p.parent.mkdir(parents=True,exist_ok=True); torch.save(checkpoint,p)
|
| 199 |
+
print("[validation]"); [print(f" {k}: {v}") for k,v in metrics.items()]; print(f"[saved] {p}")
|
| 200 |
+
return metrics
|
raven/transforms.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
import io
|
| 3 |
+
import random
|
| 4 |
+
import numpy as np
|
| 5 |
+
from PIL import Image, ImageEnhance, ImageFilter, ImageOps
|
| 6 |
+
import torch
|
| 7 |
+
from torchvision import transforms as T
|
| 8 |
+
|
| 9 |
+
IMAGENET_MEAN = (0.485, 0.456, 0.406)
|
| 10 |
+
IMAGENET_STD = (0.229, 0.224, 0.225)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def jpeg_recompress(img: Image.Image, quality: int) -> Image.Image:
|
| 14 |
+
b = io.BytesIO()
|
| 15 |
+
img.convert("RGB").save(b, format="JPEG", quality=int(quality), optimize=False)
|
| 16 |
+
b.seek(0)
|
| 17 |
+
return Image.open(b).convert("RGB")
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def sensor_noise(img: Image.Image, sigma: float) -> Image.Image:
|
| 21 |
+
a = np.asarray(img.convert("RGB"), dtype=np.float32) / 255.0
|
| 22 |
+
read = np.random.normal(0, sigma, a.shape).astype(np.float32)
|
| 23 |
+
shot = np.random.normal(0, np.sqrt(np.clip(a, 0, 1) + 1e-4) * sigma * 0.35).astype(np.float32)
|
| 24 |
+
a = np.clip(a + read + shot, 0, 1)
|
| 25 |
+
return Image.fromarray((a * 255 + .5).astype(np.uint8), mode="RGB")
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class SymmetricAugment:
|
| 29 |
+
|
| 30 |
+
def __call__(self, image: Image.Image) -> Image.Image:
|
| 31 |
+
im = ImageOps.exif_transpose(image).convert("RGB")
|
| 32 |
+
if random.random() < .70:
|
| 33 |
+
im = jpeg_recompress(im, random.randint(35, 100))
|
| 34 |
+
if random.random() < .15:
|
| 35 |
+
im = jpeg_recompress(im, random.randint(45, 92))
|
| 36 |
+
if random.random() < .52:
|
| 37 |
+
w, h = im.size; s = random.uniform(.45, 1.25)
|
| 38 |
+
im = im.resize((max(64, int(w*s)), max(64, int(h*s))), random.choice([
|
| 39 |
+
Image.Resampling.BICUBIC, Image.Resampling.BILINEAR, Image.Resampling.LANCZOS]))
|
| 40 |
+
if random.random() < .20:
|
| 41 |
+
im = im.filter(ImageFilter.GaussianBlur(random.uniform(.1, 1.25)))
|
| 42 |
+
if random.random() < .12:
|
| 43 |
+
im = im.filter(ImageFilter.UnsharpMask(radius=1, percent=random.randint(60, 130), threshold=2))
|
| 44 |
+
if random.random() < .25:
|
| 45 |
+
im = ImageEnhance.Brightness(im).enhance(random.uniform(.68, 1.30))
|
| 46 |
+
if random.random() < .25:
|
| 47 |
+
im = ImageEnhance.Contrast(im).enhance(random.uniform(.72, 1.28))
|
| 48 |
+
if random.random() < .15:
|
| 49 |
+
im = ImageEnhance.Color(im).enhance(random.uniform(.72, 1.28))
|
| 50 |
+
if random.random() < .18:
|
| 51 |
+
im = sensor_noise(im, random.uniform(.002, .016))
|
| 52 |
+
if random.random() < .18:
|
| 53 |
+
w, h = im.size; s = random.uniform(.60, .92)
|
| 54 |
+
tmp = im.resize((max(64, int(w*s)), max(64, int(h*s))), Image.Resampling.BICUBIC)
|
| 55 |
+
im = tmp.resize((w, h), Image.Resampling.BILINEAR)
|
| 56 |
+
if random.random() < .7:
|
| 57 |
+
im = jpeg_recompress(im, random.randint(55, 92))
|
| 58 |
+
return im
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def foundation_transform(size: int = 224):
|
| 62 |
+
return T.Compose([
|
| 63 |
+
T.Resize(size + 32, interpolation=T.InterpolationMode.BICUBIC),
|
| 64 |
+
T.CenterCrop(size),
|
| 65 |
+
T.ToTensor(),
|
| 66 |
+
T.Normalize(IMAGENET_MEAN, IMAGENET_STD),
|
| 67 |
+
])
|
raven/utils.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
import hashlib
|
| 3 |
+
import json
|
| 4 |
+
import os
|
| 5 |
+
import random
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Iterable
|
| 8 |
+
import numpy as np
|
| 9 |
+
import torch
|
| 10 |
+
|
| 11 |
+
IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tif", ".tiff"}
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def seed_everything(seed: int) -> None:
|
| 15 |
+
random.seed(seed)
|
| 16 |
+
np.random.seed(seed)
|
| 17 |
+
torch.manual_seed(seed)
|
| 18 |
+
if torch.cuda.is_available():
|
| 19 |
+
torch.cuda.manual_seed_all(seed)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def configure_cpu(threads: int = 6, interop_threads: int = 1) -> None:
|
| 23 |
+
threads = max(1, int(threads))
|
| 24 |
+
os.environ.setdefault("OMP_NUM_THREADS", str(threads))
|
| 25 |
+
os.environ.setdefault("MKL_NUM_THREADS", str(threads))
|
| 26 |
+
torch.set_num_threads(threads)
|
| 27 |
+
try:
|
| 28 |
+
torch.set_num_interop_threads(max(1, int(interop_threads)))
|
| 29 |
+
except RuntimeError:
|
| 30 |
+
pass
|
| 31 |
+
torch.backends.mkldnn.enabled = True
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def stable_unit(text: str) -> float:
|
| 35 |
+
h = hashlib.sha256(text.encode("utf-8")).digest()
|
| 36 |
+
return int.from_bytes(h[:8], "big") / float(2**64 - 1)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def iter_images(root: str | Path) -> Iterable[Path]:
|
| 40 |
+
root = Path(root)
|
| 41 |
+
if not root.exists():
|
| 42 |
+
return
|
| 43 |
+
for p in root.rglob("*"):
|
| 44 |
+
if p.is_file() and p.suffix.lower() in IMAGE_EXTS:
|
| 45 |
+
yield p
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def read_jsonl(path: str | Path) -> list[dict]:
|
| 49 |
+
rows: list[dict] = []
|
| 50 |
+
with Path(path).open("r", encoding="utf-8") as f:
|
| 51 |
+
for line in f:
|
| 52 |
+
line = line.strip()
|
| 53 |
+
if line:
|
| 54 |
+
rows.append(json.loads(line))
|
| 55 |
+
return rows
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def write_jsonl(path: str | Path, rows: Iterable[dict]) -> None:
|
| 59 |
+
p = Path(path)
|
| 60 |
+
p.parent.mkdir(parents=True, exist_ok=True)
|
| 61 |
+
with p.open("w", encoding="utf-8") as f:
|
| 62 |
+
for row in rows:
|
| 63 |
+
f.write(json.dumps(row, ensure_ascii=False) + "\n")
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def safe_source_name(source: str) -> str:
|
| 67 |
+
return "".join(c if c.isalnum() or c in "-_." else "_" for c in str(source))[:96]
|