synth-curator 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- synth_curator/__init__.py +7 -0
- synth_curator/__main__.py +5 -0
- synth_curator/cli.py +49 -0
- synth_curator/core/__init__.py +1 -0
- synth_curator/core/block_search.py +134 -0
- synth_curator/core/distributional.py +109 -0
- synth_curator/core/features.py +43 -0
- synth_curator/core/fingerprint.py +31 -0
- synth_curator/core/geometry.py +25 -0
- synth_curator/core/groups.py +87 -0
- synth_curator/core/output.py +45 -0
- synth_curator/core/registry.py +61 -0
- synth_curator/core/safety.py +70 -0
- synth_curator/core/sampling.py +65 -0
- synth_curator/core/scoring.py +75 -0
- synth_curator/exceptions.py +17 -0
- synth_curator/model_interface.py +136 -0
- synth_curator/pipeline.py +588 -0
- synth_curator/py.typed +0 -0
- synth_curator/shipped/isic2018_resnet50/meta.json +52 -0
- synth_curator/shipped/isic2018_resnet50/weights.json +121 -0
- synth_curator/shipped/isic2018_swin_b/meta.json +53 -0
- synth_curator/shipped/isic2018_swin_b/weights.json +121 -0
- synth_curator/steps/__init__.py +1 -0
- synth_curator/steps/curate.py +105 -0
- synth_curator/steps/features.py +132 -0
- synth_curator/steps/metric_sets.py +99 -0
- synth_curator/steps/metrics/__init__.py +112 -0
- synth_curator/steps/metrics/from_features.py +68 -0
- synth_curator/steps/metrics/paired.py +149 -0
- synth_curator/steps/metrics/single_image.py +79 -0
- synth_curator/steps/standardize.py +75 -0
- synth_curator/steps/tournament.py +140 -0
- synth_curator/steps/weights.py +123 -0
- synth_curator/weights_bundle.py +93 -0
- synth_curator-0.1.0.dist-info/METADATA +509 -0
- synth_curator-0.1.0.dist-info/RECORD +40 -0
- synth_curator-0.1.0.dist-info/WHEEL +4 -0
- synth_curator-0.1.0.dist-info/entry_points.txt +2 -0
- synth_curator-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""synth-curator: utility-weighted curation of synthetic image pools."""
|
|
2
|
+
__version__ = "0.1.0" # set before the imports below - weights_bundle.py reads it during import
|
|
3
|
+
|
|
4
|
+
from .model_interface import Model, ProductionModel, model_from_function
|
|
5
|
+
from .pipeline import SynthCurator
|
|
6
|
+
|
|
7
|
+
__all__ = ["Model", "ProductionModel", "SynthCurator", "model_from_function"]
|
synth_curator/cli.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Command-line entry point: `synth-curator <config.yaml>`.
|
|
2
|
+
|
|
3
|
+
Builds a SynthCurator from the config and runs it - the literal top-level starting point for
|
|
4
|
+
someone who has real images, a synthetic pool, and a config file, and wants to run the whole
|
|
5
|
+
pipeline without wiring the Python API themselves.
|
|
6
|
+
|
|
7
|
+
$ synth-curator config.yaml # -> curated manifest CSV path
|
|
8
|
+
$ synth-curator config.yaml --train-production # -> also trains the production model
|
|
9
|
+
$ python -m synth_curator config.yaml
|
|
10
|
+
|
|
11
|
+
With a config `resample:` section, both of the above sweep N seeds (SynthCurator.resample())
|
|
12
|
+
instead of running once - see config.example.yaml.
|
|
13
|
+
|
|
14
|
+
The model comes from the config's `model:` block (a dotted `trainer` path); this entry point has
|
|
15
|
+
no way to pass a Python Model object directly - use SynthCurator.from_config in your own script
|
|
16
|
+
for that.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import logging
|
|
22
|
+
|
|
23
|
+
from .pipeline import SynthCurator
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def main(argv: list[str] | None = None) -> int:
|
|
27
|
+
parser = argparse.ArgumentParser(prog="synth-curator")
|
|
28
|
+
parser.add_argument("config", help="path to a config.example.yaml-style YAML file")
|
|
29
|
+
parser.add_argument("-q", "--quiet", action="store_true", help="only show warnings and errors")
|
|
30
|
+
parser.add_argument("--train-production", action="store_true",
|
|
31
|
+
help="also train the production model on the curated set (needs your "
|
|
32
|
+
"model's trainer to implement train_production); prints whatever it returns")
|
|
33
|
+
args = parser.parse_args(argv)
|
|
34
|
+
|
|
35
|
+
logging.basicConfig(level=logging.WARNING if args.quiet else logging.INFO, format="%(message)s")
|
|
36
|
+
|
|
37
|
+
sc = SynthCurator.from_config(args.config)
|
|
38
|
+
seeds = SynthCurator.resample_seeds_from_config(args.config)
|
|
39
|
+
if seeds is None:
|
|
40
|
+
print(sc.train_production() if args.train_production else sc.run())
|
|
41
|
+
else:
|
|
42
|
+
for seed, resampled in sc.resample(seeds).items():
|
|
43
|
+
result = resampled.train_production() if args.train_production else resampled.run()
|
|
44
|
+
print(f"seed={seed}: {result}")
|
|
45
|
+
return 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
if __name__ == "__main__":
|
|
49
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Shared math: scoring, FID/KID/SWD, sampling, safety, groups, registry, fingerprint."""
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Optional per-class block search (block_search=True): distributionally score each class of each
|
|
2
|
+
drawn block against the same class of the real set, then pick the best block.
|
|
3
|
+
|
|
4
|
+
Port of v14 evaluate_distributional_utility_per_class / select_optimal_block_per_class /
|
|
5
|
+
write_class_log. Off by default in v15 (the winning block's U_dist did not track downstream F2).
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from collections import defaultdict
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
import pandas as pd
|
|
15
|
+
import torch
|
|
16
|
+
|
|
17
|
+
from .distributional import calculate_fid, calculate_kid, calculate_swd, get_stats
|
|
18
|
+
from .features import load_features, real_class_lookup, stack_synthetic
|
|
19
|
+
from .registry import DEFAULT_DIST_MIN_SAMPLES, is_distributional, props
|
|
20
|
+
from .sampling import class_index
|
|
21
|
+
from .scoring import calculate_robust_bounds, calculate_robust_score
|
|
22
|
+
|
|
23
|
+
log = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _usable_dist_metrics(dist_metrics, n_real, n_synth, min_samples):
|
|
27
|
+
"""Which distributional metrics a class has enough samples to estimate."""
|
|
28
|
+
n = min(n_real, n_synth)
|
|
29
|
+
return [m for m in dist_metrics if n >= min_samples[props(m)["family"]]]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def evaluate_distributional_utility_per_class(blocks, per_class_weights, features_dir: Path,
|
|
33
|
+
classes: list[str], min_samples: dict | None = None):
|
|
34
|
+
"""Sets per block: U_dist_by_class, U_dist (mean over scored classes), dist_detail, distributional_scores."""
|
|
35
|
+
min_samples = {**DEFAULT_DIST_MIN_SAMPLES, **(min_samples or {})}
|
|
36
|
+
idx = class_index({c: 0 for c in classes})
|
|
37
|
+
all_dist = sorted({m for w in per_class_weights.values() for m in w if is_distributional(m)})
|
|
38
|
+
for b in blocks:
|
|
39
|
+
b["U_dist"], b["U_dist_by_class"], b["dist_detail"], b["distributional_scores"] = 0.0, {}, [], {}
|
|
40
|
+
if not all_dist:
|
|
41
|
+
log.info("no distributional metrics in the weights: U_dist = 0 for all blocks")
|
|
42
|
+
return blocks
|
|
43
|
+
|
|
44
|
+
lookup = real_class_lookup(features_dir)
|
|
45
|
+
spaces = sorted({props(m)["space"] for m in all_dist})
|
|
46
|
+
synth = {s: load_features(features_dir, "synthetic", s) for s in spaces}
|
|
47
|
+
real_by_class = {}
|
|
48
|
+
for s in spaces:
|
|
49
|
+
buckets = defaultdict(list)
|
|
50
|
+
for key, vec in load_features(features_dir, "real", s).items():
|
|
51
|
+
class_name = lookup.get(Path(key).stem)
|
|
52
|
+
if class_name in idx:
|
|
53
|
+
buckets[class_name].append(vec)
|
|
54
|
+
real_by_class[s] = {c: torch.stack(v).float() for c, v in buckets.items()}
|
|
55
|
+
|
|
56
|
+
real_stats_cache = {}
|
|
57
|
+
for class_name in classes:
|
|
58
|
+
weights = per_class_weights.get(class_name, {})
|
|
59
|
+
cls_dist = [m for m in all_dist if m in weights]
|
|
60
|
+
if not cls_dist:
|
|
61
|
+
continue
|
|
62
|
+
n_real = max(len(real_by_class[props(m)["space"]].get(class_name, [])) for m in cls_dist)
|
|
63
|
+
n_synth = min(int((b["df"]["target_class_label"] == class_name).sum()) for b in blocks)
|
|
64
|
+
usable = _usable_dist_metrics(cls_dist, n_real, n_synth, min_samples)
|
|
65
|
+
if not usable:
|
|
66
|
+
log.info("[%s] n_real=%d n_synth=%d: no estimable metric, class skipped", class_name, n_real, n_synth)
|
|
67
|
+
continue
|
|
68
|
+
share = sum(weights[m] for m in usable)
|
|
69
|
+
if share <= 0:
|
|
70
|
+
continue
|
|
71
|
+
renorm = {m: weights[m] / share for m in usable}
|
|
72
|
+
|
|
73
|
+
for m in usable:
|
|
74
|
+
p = props(m)
|
|
75
|
+
real_tensor = real_by_class[p["space"]][class_name]
|
|
76
|
+
raw_scores = []
|
|
77
|
+
for b in blocks:
|
|
78
|
+
names = b["df"].loc[b["df"]["target_class_label"] == class_name, "filename"].tolist()
|
|
79
|
+
st = stack_synthetic(synth[p["space"]], names)
|
|
80
|
+
if p["family"] == "fid":
|
|
81
|
+
key = (class_name, p["space"])
|
|
82
|
+
if key not in real_stats_cache:
|
|
83
|
+
real_stats_cache[key] = get_stats(real_tensor)
|
|
84
|
+
mu_r, sg_r = real_stats_cache[key]
|
|
85
|
+
mu_s, sg_s = get_stats(st)
|
|
86
|
+
score = calculate_fid(mu_s, sg_s, mu_r, sg_r)
|
|
87
|
+
elif p["family"] == "kid":
|
|
88
|
+
score = calculate_kid(st, real_tensor)
|
|
89
|
+
else: # swd needs equal row counts: trim the longer side
|
|
90
|
+
a, r = st, real_tensor
|
|
91
|
+
if a.shape[0] != r.shape[0]:
|
|
92
|
+
keep = min(a.shape[0], r.shape[0])
|
|
93
|
+
g = torch.Generator().manual_seed(b["idx"] * 1000 + idx[class_name])
|
|
94
|
+
if a.shape[0] > keep:
|
|
95
|
+
a = a[torch.randperm(a.shape[0], generator=g)[:keep]]
|
|
96
|
+
if r.shape[0] > keep:
|
|
97
|
+
r = r[torch.randperm(r.shape[0], generator=g)[:keep]]
|
|
98
|
+
score = calculate_swd(a, r, generator=torch.Generator().manual_seed(idx[class_name] * 97 + len(m)))
|
|
99
|
+
raw_scores.append(score)
|
|
100
|
+
|
|
101
|
+
series = pd.Series(raw_scores)
|
|
102
|
+
vmin, vmax = calculate_robust_bounds(series)
|
|
103
|
+
norm = calculate_robust_score(series, vmin, vmax, p["optimize"])
|
|
104
|
+
for i, b in enumerate(blocks):
|
|
105
|
+
b["U_dist_by_class"][class_name] = b["U_dist_by_class"].get(class_name, 0.0) + renorm[m] * float(norm.iloc[i])
|
|
106
|
+
b["dist_detail"].append((class_name, m, raw_scores[i], float(norm.iloc[i])))
|
|
107
|
+
b["distributional_scores"][f"Raw_{class_name}_{m}"] = raw_scores[i]
|
|
108
|
+
b["distributional_scores"][f"Normalized_{class_name}_{m}"] = float(norm.iloc[i])
|
|
109
|
+
|
|
110
|
+
for b in blocks:
|
|
111
|
+
scored = b["U_dist_by_class"]
|
|
112
|
+
b["U_dist"] = float(np.mean(list(scored.values()))) if scored else 0.0
|
|
113
|
+
return blocks
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def select_optimal_block_per_class(blocks):
|
|
117
|
+
"""Argmax of U_dist. Selection is purely distributional (instance quality already shaped the draw);
|
|
118
|
+
U_inst is recorded for logging only."""
|
|
119
|
+
best, best_score = None, -float("inf")
|
|
120
|
+
for b in blocks:
|
|
121
|
+
b["U_inst"] = b["df"]["S_Composite"].mean()
|
|
122
|
+
b["S_Final"] = b["U_dist"]
|
|
123
|
+
if b["U_dist"] > best_score:
|
|
124
|
+
best, best_score = b, b["U_dist"]
|
|
125
|
+
log.info("winner: block %d (U_dist=%.4f)", best["idx"], best_score)
|
|
126
|
+
return best["df"], blocks
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def write_class_log(blocks, path: Path) -> None:
|
|
130
|
+
rows = [{"Block_ID": b["idx"], "target_class_label": class_name, "metric": m, "raw": raw, "normalized": norm,
|
|
131
|
+
"U_dist_class": b["U_dist_by_class"].get(class_name), "U_dist_block": b["U_dist"]}
|
|
132
|
+
for b in blocks for class_name, m, raw, norm in b["dist_detail"]]
|
|
133
|
+
if rows:
|
|
134
|
+
pd.DataFrame(rows).to_csv(path, index=False)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""FID / KID / SWD (+ precision/recall helpers) on feature tensors.
|
|
2
|
+
|
|
3
|
+
Port of metric_pipeline/torch_metrics.py; math unchanged.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
import torch
|
|
11
|
+
from scipy import linalg
|
|
12
|
+
|
|
13
|
+
log = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_stats(features: torch.Tensor, eps: float = 1e-6):
|
|
17
|
+
"""Mean and regularized covariance of a feature set (float64)."""
|
|
18
|
+
mu = torch.mean(features, dim=0, dtype=torch.float64)
|
|
19
|
+
sigma = torch.cov(features.to(torch.double).T)
|
|
20
|
+
sigma = sigma + torch.eye(sigma.shape[0], device=features.device, dtype=torch.float64) * eps
|
|
21
|
+
return mu, sigma
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def calculate_fid(mu1: torch.Tensor, sigma1: torch.Tensor,
|
|
25
|
+
mu2: torch.Tensor, sigma2: torch.Tensor) -> float:
|
|
26
|
+
"""Frechet distance between two Gaussians (synthetic first, real second). Lower is better."""
|
|
27
|
+
mu1_np = mu1.cpu().numpy().astype(np.float64)
|
|
28
|
+
sigma1_np = sigma1.cpu().numpy().astype(np.float64)
|
|
29
|
+
mu2_np = mu2.cpu().numpy().astype(np.float64)
|
|
30
|
+
sigma2_np = sigma2.cpu().numpy().astype(np.float64)
|
|
31
|
+
|
|
32
|
+
diff = mu1_np - mu2_np
|
|
33
|
+
mean_term = diff.dot(diff)
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
covmean = linalg.sqrtm(sigma1_np @ sigma2_np)
|
|
37
|
+
except linalg.LinAlgError:
|
|
38
|
+
log.warning("linalg.sqrtm failed to converge; returning inf")
|
|
39
|
+
return float("inf")
|
|
40
|
+
|
|
41
|
+
if np.iscomplexobj(covmean):
|
|
42
|
+
covmean = covmean.real
|
|
43
|
+
|
|
44
|
+
if not np.isfinite(covmean).all():
|
|
45
|
+
log.warning("sqrtm returned non-finite values; retrying with a small diagonal offset")
|
|
46
|
+
offset = np.eye(sigma1_np.shape[0]) * 1e-6
|
|
47
|
+
covmean = linalg.sqrtm((sigma1_np + offset) @ (sigma2_np + offset))
|
|
48
|
+
if np.iscomplexobj(covmean):
|
|
49
|
+
covmean = covmean.real
|
|
50
|
+
|
|
51
|
+
fid = mean_term + np.trace(sigma1_np + sigma2_np - 2 * covmean)
|
|
52
|
+
return 0.0 if fid < 0 else float(fid)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def calculate_swd(features1, features2, n_projections=128, generator=None) -> float:
|
|
56
|
+
"""Sliced Wasserstein distance; equal row counts required. Pass a seeded generator for reproducibility."""
|
|
57
|
+
features1 = features1.cpu().to(torch.float32)
|
|
58
|
+
features2 = features2.cpu().to(torch.float32)
|
|
59
|
+
projections = torch.randn(features1.shape[1], n_projections, generator=generator)
|
|
60
|
+
projections = projections / torch.linalg.norm(projections, dim=0, keepdim=True)
|
|
61
|
+
sorted1, _ = torch.sort(features1 @ projections, dim=0)
|
|
62
|
+
sorted2, _ = torch.sort(features2 @ projections, dim=0)
|
|
63
|
+
return torch.mean(torch.abs(sorted1 - sorted2)).item()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _polynomial_kernel(X, Y, degree=3, gamma=None, coef0=1):
|
|
67
|
+
if gamma is None:
|
|
68
|
+
gamma = 1.0 / X.shape[1]
|
|
69
|
+
return (X @ Y.T * gamma + coef0) ** degree
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def calculate_kid(features1, features2) -> float:
|
|
73
|
+
"""Kernel Inception Distance (polynomial kernel)."""
|
|
74
|
+
features1 = features1.cpu().to(torch.float32)
|
|
75
|
+
features2 = features2.cpu().to(torch.float32)
|
|
76
|
+
k_xx = _polynomial_kernel(features1, features1)
|
|
77
|
+
k_yy = _polynomial_kernel(features2, features2)
|
|
78
|
+
k_xy = _polynomial_kernel(features1, features2)
|
|
79
|
+
return (k_xx.mean() + k_yy.mean() - 2 * k_xy.mean()).item()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def calculate_per_image_kl_divergence(probabilities: torch.Tensor, eps: float = 1e-16) -> torch.Tensor:
|
|
83
|
+
"""Per-image KL(p(y|x) || p(y)): the per-image term of the Inception Score."""
|
|
84
|
+
eps_t = torch.tensor(eps, device=probabilities.device, dtype=torch.float32)
|
|
85
|
+
p_y = probabilities.mean(dim=0, keepdim=True)
|
|
86
|
+
return torch.sum(probabilities * (torch.log(probabilities + eps_t) - torch.log(p_y + eps_t)), dim=1)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def calculate_precision_recall_scores(real_features, synth_features, k=3) -> dict:
|
|
90
|
+
"""Global precision/recall plus per-image realism/coverage (k-NN distances). Uses the GPU if present."""
|
|
91
|
+
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
|
92
|
+
real_features = real_features.to(device)
|
|
93
|
+
synth_features = synth_features.to(device)
|
|
94
|
+
|
|
95
|
+
def kth(dists, k_):
|
|
96
|
+
return torch.kthvalue(dists, k=k_, dim=1)[0]
|
|
97
|
+
|
|
98
|
+
per_image_realism = kth(torch.cdist(synth_features, real_features, p=2), k)
|
|
99
|
+
per_image_coverage = kth(torch.cdist(real_features, synth_features, p=2), k)
|
|
100
|
+
real_manifold = kth(torch.cdist(real_features, real_features, p=2), k + 1)
|
|
101
|
+
|
|
102
|
+
precision = (per_image_realism.unsqueeze(1) <= real_manifold.unsqueeze(0)).any(dim=1).float().mean().item()
|
|
103
|
+
recall = (per_image_coverage.unsqueeze(1) <= real_manifold.unsqueeze(0)).any(dim=1).float().mean().item()
|
|
104
|
+
return {
|
|
105
|
+
"precision": precision,
|
|
106
|
+
"recall": recall,
|
|
107
|
+
"per_image_realism_scores": per_image_realism.cpu(),
|
|
108
|
+
"per_image_coverage_scores": per_image_coverage.cpu(),
|
|
109
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Feature-tensor access. A feature space is two dicts {filename: 1-D tensor}, one for the real
|
|
2
|
+
reference set and one for the synthetic pool, stored as `{real,synthetic}_{space}.pt` in
|
|
3
|
+
`features_dir` (space = dino | inception). Real class labels (only needed for block_search) live
|
|
4
|
+
in `real_labels.csv` (columns: filename, class)."""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import pandas as pd
|
|
10
|
+
import torch
|
|
11
|
+
|
|
12
|
+
from ..exceptions import MissingFeaturesError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def feature_path(features_dir: Path, role: str, space: str) -> Path:
|
|
16
|
+
return Path(features_dir) / f"{role}_{space}.pt"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def load_features(features_dir: Path, role: str, space: str) -> dict[str, torch.Tensor]:
|
|
20
|
+
path = feature_path(features_dir, role, space)
|
|
21
|
+
if not path.exists():
|
|
22
|
+
raise MissingFeaturesError(f"missing feature file {path}")
|
|
23
|
+
return torch.load(path, map_location="cpu")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def stack_real(real: dict[str, torch.Tensor]) -> torch.Tensor:
|
|
27
|
+
return torch.stack(list(real.values())).float()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def stack_synthetic(synth: dict[str, torch.Tensor], filenames) -> torch.Tensor:
|
|
31
|
+
try:
|
|
32
|
+
return torch.stack([synth[f] for f in filenames]).float()
|
|
33
|
+
except KeyError as e:
|
|
34
|
+
raise MissingFeaturesError(f"image {e} is in the metadata but not in the feature dict") from None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def real_class_lookup(features_dir: Path) -> dict[str, str]:
|
|
38
|
+
"""{filename stem: class} for the real reference set (FS-CS and block_search need it)."""
|
|
39
|
+
path = Path(features_dir) / "real_labels.csv"
|
|
40
|
+
if not path.exists():
|
|
41
|
+
raise MissingFeaturesError(f"missing {path} (columns: filename, class); FS-CS and block_search need real class labels")
|
|
42
|
+
df = pd.read_csv(path)
|
|
43
|
+
return {Path(f).stem: c for f, c in zip(df["filename"], df["class"])}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Marks a cached output with the inputs that built it, so a later call can tell it's stale. No
|
|
2
|
+
fingerprint file present is treated as a match (never invalidates a cache it didn't itself write)."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _path(out_dir: Path) -> Path:
|
|
11
|
+
return Path(str(out_dir) + ".fingerprint.json")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def exists(out_dir: Path) -> bool:
|
|
15
|
+
return _path(out_dir).exists()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def matches(out_dir: Path, current: dict[str, Any]) -> bool:
|
|
19
|
+
"""True only if a fingerprint is present AND equals `current` exactly."""
|
|
20
|
+
path = _path(out_dir)
|
|
21
|
+
if not path.exists():
|
|
22
|
+
return False
|
|
23
|
+
try:
|
|
24
|
+
stored = json.loads(path.read_text())
|
|
25
|
+
except (json.JSONDecodeError, OSError):
|
|
26
|
+
return False
|
|
27
|
+
return stored == current
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def write(out_dir: Path, current: dict[str, Any]) -> None:
|
|
31
|
+
_path(out_dir).write_text(json.dumps(current, indent=2, sort_keys=True))
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Cosine k-NN in feature space (a plain-torch replacement for a FAISS dependency).
|
|
2
|
+
|
|
3
|
+
Distances match FAISS IndexFlatL2 on L2-normalized vectors: squared L2 = 2 - 2*cos.
|
|
4
|
+
Used by the KNN map, realism (synthetic vs real) and diversity (synthetic vs synthetic).
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import torch
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def knn_cosine(query: torch.Tensor, db: torch.Tensor, k: int, chunk: int = 2048):
|
|
12
|
+
"""Returns (D, I): squared-L2 distances and indices of the k nearest `db` rows per `query` row, ascending."""
|
|
13
|
+
q = query.float()
|
|
14
|
+
d = db.float()
|
|
15
|
+
q = q / (q.norm(dim=1, keepdim=True) + 1e-8)
|
|
16
|
+
d = d / (d.norm(dim=1, keepdim=True) + 1e-8)
|
|
17
|
+
if torch.cuda.is_available():
|
|
18
|
+
d = d.cuda()
|
|
19
|
+
dists, idxs = [], []
|
|
20
|
+
for i in range(0, len(q), chunk):
|
|
21
|
+
sim = q[i:i + chunk].to(d.device) @ d.T
|
|
22
|
+
top = sim.topk(k, dim=1)
|
|
23
|
+
dists.append((2 - 2 * top.values).clamp_min(0).cpu())
|
|
24
|
+
idxs.append(top.indices.cpu())
|
|
25
|
+
return torch.cat(dists).numpy(), torch.cat(idxs).numpy()
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Group distributional scores: one score per (generation group, distributional metric),
|
|
2
|
+
normalized to [0,1] across groups (1.0 = best), inherited by every image in the group.
|
|
3
|
+
|
|
4
|
+
Port of v15 compute_group_scores. Computed live from feature tensors so any `dist_group_by`
|
|
5
|
+
partition works. Groups are large enough that FID is well estimated (per-class it is not).
|
|
6
|
+
Group scores are global (all classes pooled); how much a class trusts them is per class (weights).
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import pandas as pd
|
|
15
|
+
import torch
|
|
16
|
+
|
|
17
|
+
from .distributional import calculate_fid, calculate_kid, calculate_swd, get_stats
|
|
18
|
+
from .features import load_features, stack_real, stack_synthetic
|
|
19
|
+
from .registry import props
|
|
20
|
+
from .scoring import calculate_robust_bounds, calculate_robust_score, group_key
|
|
21
|
+
|
|
22
|
+
log = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
SWD_MAX_N = 8192 # cap the group-vs-real SWD sample so many groups stay affordable
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def compute_group_raw(df: pd.DataFrame, features_dir: Path, group_cols: list[str],
|
|
28
|
+
dist_metrics: list[str]) -> pd.DataFrame:
|
|
29
|
+
"""Raw distributional value per (group, metric); lower is better for all of them."""
|
|
30
|
+
df = df.assign(_group=group_key(df, group_cols))
|
|
31
|
+
groups = sorted(df["_group"].unique())
|
|
32
|
+
log.info("computing group scores over %s: %d groups", group_cols, len(groups))
|
|
33
|
+
|
|
34
|
+
spaces = sorted({props(m)["space"] for m in dist_metrics})
|
|
35
|
+
real = {s: stack_real(load_features(features_dir, "real", s)) for s in spaces}
|
|
36
|
+
synth = {s: load_features(features_dir, "synthetic", s) for s in spaces}
|
|
37
|
+
|
|
38
|
+
real_stats: dict[str, tuple] = {}
|
|
39
|
+
raw: dict[str, list[float]] = {m: [] for m in dist_metrics}
|
|
40
|
+
for gi, g in enumerate(groups):
|
|
41
|
+
names = df.loc[df["_group"] == g, "filename"].tolist()
|
|
42
|
+
for m in dist_metrics:
|
|
43
|
+
p = props(m)
|
|
44
|
+
rt = real[p["space"]]
|
|
45
|
+
st = stack_synthetic(synth[p["space"]], names)
|
|
46
|
+
if p["family"] == "fid":
|
|
47
|
+
if p["space"] not in real_stats:
|
|
48
|
+
real_stats[p["space"]] = get_stats(rt)
|
|
49
|
+
mu_r, sg_r = real_stats[p["space"]]
|
|
50
|
+
mu_s, sg_s = get_stats(st)
|
|
51
|
+
raw[m].append(calculate_fid(mu_s, sg_s, mu_r, sg_r))
|
|
52
|
+
elif p["family"] == "kid":
|
|
53
|
+
raw[m].append(calculate_kid(st, rt))
|
|
54
|
+
else:
|
|
55
|
+
gen = torch.Generator().manual_seed(gi)
|
|
56
|
+
n = min(st.shape[0], rt.shape[0], SWD_MAX_N)
|
|
57
|
+
a = st[torch.randperm(st.shape[0], generator=gen)[:n]]
|
|
58
|
+
b = rt[torch.randperm(rt.shape[0], generator=gen)[:n]]
|
|
59
|
+
raw[m].append(calculate_swd(a, b, generator=torch.Generator().manual_seed(gi)))
|
|
60
|
+
|
|
61
|
+
return pd.DataFrame({m: pd.Series(raw[m], index=groups) for m in dist_metrics})
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def compute_group_scores(df: pd.DataFrame, features_dir: Path, group_cols: list[str],
|
|
65
|
+
dist_metrics: list[str], cache_path: Path | None = None) -> pd.DataFrame:
|
|
66
|
+
"""Raw group values normalized across groups to [0,1], 1.0 = best."""
|
|
67
|
+
if cache_path is not None and Path(cache_path).exists():
|
|
68
|
+
g = pd.read_csv(cache_path, index_col=0)
|
|
69
|
+
if set(dist_metrics).issubset(g.columns):
|
|
70
|
+
log.info("group scores loaded from cache %s", cache_path)
|
|
71
|
+
return g
|
|
72
|
+
|
|
73
|
+
raw = compute_group_raw(df, features_dir, group_cols, dist_metrics)
|
|
74
|
+
scores = {}
|
|
75
|
+
for m in dist_metrics:
|
|
76
|
+
vmin, vmax = calculate_robust_bounds(raw[m])
|
|
77
|
+
scores[m] = calculate_robust_score(raw[m], vmin, vmax, props(m)["optimize"])
|
|
78
|
+
log.info("%s best group: %s (raw %.4f)", m, scores[m].idxmax(), raw[m][scores[m].idxmax()])
|
|
79
|
+
out = pd.DataFrame(scores)
|
|
80
|
+
|
|
81
|
+
if cache_path is not None:
|
|
82
|
+
cache_path = Path(cache_path)
|
|
83
|
+
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
tmp = cache_path.with_suffix(cache_path.suffix + f".tmp{os.getpid()}")
|
|
85
|
+
out.to_csv(tmp)
|
|
86
|
+
os.replace(tmp, cache_path)
|
|
87
|
+
return out
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Curation outputs: manifest CSV, block log, weights-used JSON.
|
|
2
|
+
|
|
3
|
+
Port of create_csv_manifest (metric_pipeline/dataset_curation/main.py) and generate_evaluation_log.
|
|
4
|
+
Write the manifest BEFORE the log: the manifest is the deliverable, and a log-first write once
|
|
5
|
+
destroyed a completed curation.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import pandas as pd
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def write_manifest(selected: pd.DataFrame, out_csv: Path, image_dir: Path | None = None) -> Path:
|
|
16
|
+
"""CSV with source_path, target_class, safe_filename."""
|
|
17
|
+
out_csv.parent.mkdir(parents=True, exist_ok=True)
|
|
18
|
+
src = selected["filename"].map(lambda f: str(image_dir / f) if image_dir else str(f))
|
|
19
|
+
pd.DataFrame({
|
|
20
|
+
"source_path": src.values,
|
|
21
|
+
"target_class": selected["target_class_label"].values,
|
|
22
|
+
"safe_filename": selected["filename"].astype(str).str.replace("/", "_").str.replace("\\", "_").values,
|
|
23
|
+
}).to_csv(out_csv, index=False)
|
|
24
|
+
return out_csv
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def write_block_log(blocks: list[dict], path: Path) -> None:
|
|
28
|
+
"""Per-image scores + block-level utilities for every drawn block."""
|
|
29
|
+
frames = []
|
|
30
|
+
for b in blocks:
|
|
31
|
+
d = b["df"].copy()
|
|
32
|
+
d["Block_U_inst"], d["Block_U_dist"], d["Block_S_Final"] = b["U_inst"], b["U_dist"], b["S_Final"]
|
|
33
|
+
for k, v in b["distributional_scores"].items():
|
|
34
|
+
d[k] = v
|
|
35
|
+
frames.append(d)
|
|
36
|
+
log_df = pd.concat(frames, ignore_index=True)
|
|
37
|
+
core = [c for c in ["filename", "Block_ID", "target_class_label", "S_Composite", "Block_S_Final",
|
|
38
|
+
"Block_U_inst", "Block_U_dist"] if c in log_df.columns]
|
|
39
|
+
log_df[core + [c for c in log_df.columns if c not in core]].to_csv(path, index=False)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def write_weights_used(path: Path, weights: dict, params: dict) -> None:
|
|
43
|
+
from .. import __version__
|
|
44
|
+
path.write_text(json.dumps({"algorithm": "v15", "library_version": __version__,
|
|
45
|
+
"params": params, "weights": weights}, indent=2))
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Metric registry: name -> type, optimize direction, and for distributional metrics the
|
|
2
|
+
family (fid|kid|swd) and feature space (dino|inception) they are computed on.
|
|
3
|
+
|
|
4
|
+
Port of METRIC_PROPERTIES in framework_utils/utils.py. Mutable on purpose: add your own entry
|
|
5
|
+
(and a calculator in steps/metrics) to extend the portfolio.
|
|
6
|
+
"""
|
|
7
|
+
from ..exceptions import SynthCuratorError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _inst(optimize: str) -> dict:
|
|
11
|
+
return {"type": "instance", "optimize": optimize}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _dist(family: str, space: str) -> dict:
|
|
15
|
+
return {"type": "distributional", "optimize": "min", "family": family, "space": space}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
METRIC_PROPERTIES: dict[str, dict] = {
|
|
19
|
+
"psnr_score": _inst("max"),
|
|
20
|
+
"ssim_score": _inst("max"),
|
|
21
|
+
"fsim_score": _inst("max"),
|
|
22
|
+
"lpips_score": _inst("min"),
|
|
23
|
+
"brisqe_score": _inst("min"),
|
|
24
|
+
"fscs_score": _inst("max"),
|
|
25
|
+
"clip_similarity_cancer": _inst("max"),
|
|
26
|
+
"clip_similarity_artifact": _inst("min"),
|
|
27
|
+
"clip_log_odds_score": _inst("max"),
|
|
28
|
+
"diversity_score": _inst("max"),
|
|
29
|
+
"realism_score": _inst("min"),
|
|
30
|
+
"is_contribution_score": _inst("max"),
|
|
31
|
+
"fid_inception": _dist("fid", "inception"),
|
|
32
|
+
"fid_dino": _dist("fid", "dino"),
|
|
33
|
+
"kid_dino": _dist("kid", "dino"),
|
|
34
|
+
"swd_dino": _dist("swd", "dino"),
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
DEFAULT_DIST_MIN_SAMPLES = {"fid": 2048, "kid": 256, "swd": 64}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def props(metric: str) -> dict:
|
|
41
|
+
try:
|
|
42
|
+
return METRIC_PROPERTIES[metric]
|
|
43
|
+
except KeyError:
|
|
44
|
+
raise SynthCuratorError(f"unknown metric {metric!r}; known: {sorted(METRIC_PROPERTIES)}") from None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def is_distributional(metric: str) -> bool:
|
|
48
|
+
return props(metric)["type"] == "distributional"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def is_instance(metric: str) -> bool:
|
|
52
|
+
return props(metric)["type"] == "instance"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# The default metric portfolio (fid_dino is registered but not part of the locked configuration).
|
|
56
|
+
DEFAULT_METRICS = [
|
|
57
|
+
"clip_log_odds_score", "fscs_score", "clip_similarity_cancer", "clip_similarity_artifact",
|
|
58
|
+
"lpips_score", "fsim_score", "brisqe_score", "psnr_score", "ssim_score",
|
|
59
|
+
"diversity_score", "realism_score", "is_contribution_score",
|
|
60
|
+
"fid_inception", "kid_dino", "swd_dino",
|
|
61
|
+
]
|