Ruvadev commited on
Commit
30d60f0
·
verified ·
1 Parent(s): bd2f2f5

Upload 29 files

Browse files
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]