biasid 1.0.0__tar.gz
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.
- biasid-1.0.0/PKG-INFO +82 -0
- biasid-1.0.0/README.md +65 -0
- biasid-1.0.0/pyproject.toml +38 -0
- biasid-1.0.0/src/biasid/__init__.py +4 -0
- biasid-1.0.0/src/biasid/core.py +102 -0
- biasid-1.0.0/src/biasid/datasets.py +120 -0
- biasid-1.0.0/src/biasid/detector_wrapper.py +35 -0
- biasid-1.0.0/src/biasid/metrics.py +517 -0
- biasid-1.0.0/src/biasid/prediction.py +92 -0
- biasid-1.0.0/src/biasid/py.typed +0 -0
- biasid-1.0.0/src/biasid/transforms.py +161 -0
- biasid-1.0.0/src/biasid/visualization.py +171 -0
biasid-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: biasid
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: biasid - a tool for analyzing transformation bias and robustness of AI-generated image detectors
|
|
5
|
+
Requires-Dist: datasets>=4.8.0
|
|
6
|
+
Requires-Dist: joblib>=1.5.3
|
|
7
|
+
Requires-Dist: matplotlib>=3.10.0
|
|
8
|
+
Requires-Dist: pandas>=3.0.0
|
|
9
|
+
Requires-Dist: pillow>=12.1.0
|
|
10
|
+
Requires-Dist: scienceplots>=2.2.1
|
|
11
|
+
Requires-Dist: torch>=2.10.0
|
|
12
|
+
Requires-Dist: torchmetrics>=1.9.0
|
|
13
|
+
Requires-Dist: torchvision>=0.25.0
|
|
14
|
+
Requires-Dist: tqdm>=4.67.0
|
|
15
|
+
Requires-Python: >=3.12
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# `biasid` - A Tool for Analyzing Transformation Bias and Robustness of AI-Generated Image Detectors
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
```
|
|
22
|
+
pip install biasid
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
```python
|
|
27
|
+
import matplotlib.pyplot as plt
|
|
28
|
+
import torch
|
|
29
|
+
from torch import nn
|
|
30
|
+
from torchvision.transforms import v2
|
|
31
|
+
|
|
32
|
+
from biasid import DetectorWrapper, compute_metrics, create_bias_plot, get_score_df
|
|
33
|
+
from biasid.datasets import FolderDataset
|
|
34
|
+
from biasid.transforms import JPEG
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# toy detector, use your own instead
|
|
38
|
+
class MyDetector(nn.Module):
|
|
39
|
+
def __init__(self):
|
|
40
|
+
super().__init__()
|
|
41
|
+
self.layer = nn.Linear(3 * 32 * 32, 1)
|
|
42
|
+
|
|
43
|
+
def forward(self, images):
|
|
44
|
+
return self.layer(images.flatten(start_dim=1))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
if __name__ == "__main__":
|
|
48
|
+
my_detector = MyDetector()
|
|
49
|
+
|
|
50
|
+
# wrap your model into DetectorWrapper
|
|
51
|
+
detector = DetectorWrapper(
|
|
52
|
+
model=my_detector,
|
|
53
|
+
preprocessing=v2.CenterCrop(32),
|
|
54
|
+
postprocessing=torch.sigmoid,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# define datasets
|
|
58
|
+
real = FolderDataset(root="real")
|
|
59
|
+
fake = FolderDataset(root="fake")
|
|
60
|
+
|
|
61
|
+
# define a transformation
|
|
62
|
+
jpeg = JPEG()
|
|
63
|
+
|
|
64
|
+
# compute scores
|
|
65
|
+
score_df = get_score_df(
|
|
66
|
+
detector=detector,
|
|
67
|
+
real_dataset=real,
|
|
68
|
+
fake_dataset=fake,
|
|
69
|
+
output_dir="output/scores",
|
|
70
|
+
transform=jpeg,
|
|
71
|
+
batch_size=8,
|
|
72
|
+
num_workers=4,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# compute metrics (with bootstrapping)
|
|
76
|
+
metrics = compute_metrics(score_df)
|
|
77
|
+
metrics.save("output/metrics")
|
|
78
|
+
|
|
79
|
+
# visualize results
|
|
80
|
+
create_bias_plot(result=metrics, real_subset="real", fake_subset="fake")
|
|
81
|
+
plt.savefig("output/plots/jpeg.png")
|
|
82
|
+
```
|
biasid-1.0.0/README.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# `biasid` - A Tool for Analyzing Transformation Bias and Robustness of AI-Generated Image Detectors
|
|
2
|
+
|
|
3
|
+
## Installation
|
|
4
|
+
```
|
|
5
|
+
pip install biasid
|
|
6
|
+
```
|
|
7
|
+
|
|
8
|
+
## Usage
|
|
9
|
+
```python
|
|
10
|
+
import matplotlib.pyplot as plt
|
|
11
|
+
import torch
|
|
12
|
+
from torch import nn
|
|
13
|
+
from torchvision.transforms import v2
|
|
14
|
+
|
|
15
|
+
from biasid import DetectorWrapper, compute_metrics, create_bias_plot, get_score_df
|
|
16
|
+
from biasid.datasets import FolderDataset
|
|
17
|
+
from biasid.transforms import JPEG
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# toy detector, use your own instead
|
|
21
|
+
class MyDetector(nn.Module):
|
|
22
|
+
def __init__(self):
|
|
23
|
+
super().__init__()
|
|
24
|
+
self.layer = nn.Linear(3 * 32 * 32, 1)
|
|
25
|
+
|
|
26
|
+
def forward(self, images):
|
|
27
|
+
return self.layer(images.flatten(start_dim=1))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
if __name__ == "__main__":
|
|
31
|
+
my_detector = MyDetector()
|
|
32
|
+
|
|
33
|
+
# wrap your model into DetectorWrapper
|
|
34
|
+
detector = DetectorWrapper(
|
|
35
|
+
model=my_detector,
|
|
36
|
+
preprocessing=v2.CenterCrop(32),
|
|
37
|
+
postprocessing=torch.sigmoid,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# define datasets
|
|
41
|
+
real = FolderDataset(root="real")
|
|
42
|
+
fake = FolderDataset(root="fake")
|
|
43
|
+
|
|
44
|
+
# define a transformation
|
|
45
|
+
jpeg = JPEG()
|
|
46
|
+
|
|
47
|
+
# compute scores
|
|
48
|
+
score_df = get_score_df(
|
|
49
|
+
detector=detector,
|
|
50
|
+
real_dataset=real,
|
|
51
|
+
fake_dataset=fake,
|
|
52
|
+
output_dir="output/scores",
|
|
53
|
+
transform=jpeg,
|
|
54
|
+
batch_size=8,
|
|
55
|
+
num_workers=4,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# compute metrics (with bootstrapping)
|
|
59
|
+
metrics = compute_metrics(score_df)
|
|
60
|
+
metrics.save("output/metrics")
|
|
61
|
+
|
|
62
|
+
# visualize results
|
|
63
|
+
create_bias_plot(result=metrics, real_subset="real", fake_subset="fake")
|
|
64
|
+
plt.savefig("output/plots/jpeg.png")
|
|
65
|
+
```
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "biasid"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "biasid - a tool for analyzing transformation bias and robustness of AI-generated image detectors"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"datasets>=4.8.0",
|
|
9
|
+
"joblib>=1.5.3",
|
|
10
|
+
"matplotlib>=3.10.0",
|
|
11
|
+
"pandas>=3.0.0",
|
|
12
|
+
"pillow>=12.1.0",
|
|
13
|
+
"scienceplots>=2.2.1",
|
|
14
|
+
"torch>=2.10.0",
|
|
15
|
+
"torchmetrics>=1.9.0",
|
|
16
|
+
"torchvision>=0.25.0",
|
|
17
|
+
"tqdm>=4.67.0",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[build-system]
|
|
21
|
+
requires = ["uv_build>=0.10.12,<0.11.0"]
|
|
22
|
+
build-backend = "uv_build"
|
|
23
|
+
|
|
24
|
+
[dependency-groups]
|
|
25
|
+
dev = [
|
|
26
|
+
"pytest>=9.1.1",
|
|
27
|
+
"pytest-cov>=7.1.0",
|
|
28
|
+
"ruff>=0.16.2",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[tool.ruff]
|
|
32
|
+
target-version = "py312"
|
|
33
|
+
fix = true
|
|
34
|
+
line-length = 120
|
|
35
|
+
|
|
36
|
+
[tool.ruff.lint]
|
|
37
|
+
extend-select = ["I"]
|
|
38
|
+
extend-ignore = ["E402"]
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
import pandas as pd
|
|
5
|
+
from torch import Tensor
|
|
6
|
+
from torch.utils.data import Dataset
|
|
7
|
+
from tqdm.auto import tqdm
|
|
8
|
+
|
|
9
|
+
from biasid.datasets import Metadata
|
|
10
|
+
from biasid.detector_wrapper import DetectorWrapper
|
|
11
|
+
from biasid.prediction import get_scores
|
|
12
|
+
from biasid.transforms import LevelTransform
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_score_df(
|
|
16
|
+
detector: DetectorWrapper,
|
|
17
|
+
real_dataset: Dataset,
|
|
18
|
+
fake_dataset: Dataset,
|
|
19
|
+
output_dir: str | Path,
|
|
20
|
+
transform: LevelTransform | None = None,
|
|
21
|
+
batch_size: int = 1,
|
|
22
|
+
num_workers: int = 0,
|
|
23
|
+
) -> pd.DataFrame:
|
|
24
|
+
"""
|
|
25
|
+
Compute detection scores for real and fake images, clean and transformed.
|
|
26
|
+
All results are saved and automatically loaded during repeated calls.
|
|
27
|
+
"""
|
|
28
|
+
output_dir = Path(output_dir)
|
|
29
|
+
|
|
30
|
+
rows: list[dict[str, Any]] = []
|
|
31
|
+
|
|
32
|
+
for label, dataset in zip(["real", "fake"], [real_dataset, fake_dataset]):
|
|
33
|
+
if len(dataset) == 0:
|
|
34
|
+
raise ValueError(f"Dataset {dataset!r} is empty.")
|
|
35
|
+
dataset_id = dataset.id if hasattr(dataset, "id") else label
|
|
36
|
+
|
|
37
|
+
_write_status(dataset=dataset_id, detector=detector.id)
|
|
38
|
+
scores, metadata = get_scores(
|
|
39
|
+
detector=detector,
|
|
40
|
+
dataset=dataset,
|
|
41
|
+
batch_size=batch_size,
|
|
42
|
+
num_workers=num_workers,
|
|
43
|
+
score_file=output_dir / dataset_id / "clean.pt",
|
|
44
|
+
)
|
|
45
|
+
rows.extend(
|
|
46
|
+
_expand_prediction_rows(
|
|
47
|
+
label=label,
|
|
48
|
+
transform_name="clean",
|
|
49
|
+
level=None,
|
|
50
|
+
level_label=transform.clean_level_label if transform is not None else None,
|
|
51
|
+
metadata=metadata,
|
|
52
|
+
scores=scores,
|
|
53
|
+
)
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
if transform is not None:
|
|
57
|
+
for level in transform.levels:
|
|
58
|
+
_write_status(dataset=dataset_id, detector=detector.id, transform=transform.id, level=level)
|
|
59
|
+
scores, metadata = get_scores(
|
|
60
|
+
detector=detector,
|
|
61
|
+
dataset=dataset,
|
|
62
|
+
transform=transform.get_transform(level=level),
|
|
63
|
+
batch_size=batch_size,
|
|
64
|
+
num_workers=num_workers,
|
|
65
|
+
score_file=output_dir / dataset_id / transform.id / f"{level}.pt",
|
|
66
|
+
)
|
|
67
|
+
rows.extend(
|
|
68
|
+
_expand_prediction_rows(
|
|
69
|
+
label=label,
|
|
70
|
+
transform_name=transform.id,
|
|
71
|
+
level=level,
|
|
72
|
+
level_label=transform.get_level_label(level),
|
|
73
|
+
metadata=metadata,
|
|
74
|
+
scores=scores,
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
return pd.DataFrame(rows)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _write_status(dataset: str, detector: str, transform: str | None = None, level: Any = None) -> None:
|
|
82
|
+
transform_description = f"{transform} @ {level}" if transform is not None else "clean"
|
|
83
|
+
tqdm.write(f"Predicting {dataset} ({transform_description}) with {detector}", end="")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _expand_prediction_rows(
|
|
87
|
+
scores: Tensor,
|
|
88
|
+
metadata: list[Metadata],
|
|
89
|
+
label: str,
|
|
90
|
+
transform_name: str,
|
|
91
|
+
level: Any,
|
|
92
|
+
level_label: str | None,
|
|
93
|
+
) -> list[dict[str, Any]]:
|
|
94
|
+
rows: list[dict[str, Any]] = []
|
|
95
|
+
for score, sample_metadata in zip(scores, metadata):
|
|
96
|
+
row: dict[str, Any] = {"label": label, **sample_metadata}
|
|
97
|
+
row["transform"] = transform_name
|
|
98
|
+
row["level"] = level
|
|
99
|
+
row["level_label"] = level_label
|
|
100
|
+
row["score"] = score.item()
|
|
101
|
+
rows.append(row)
|
|
102
|
+
return rows
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
import torch
|
|
6
|
+
import torchvision.transforms.v2.functional as TF
|
|
7
|
+
from datasets import load_dataset
|
|
8
|
+
from PIL import Image
|
|
9
|
+
from torch import Tensor
|
|
10
|
+
from torch.utils.data import Dataset
|
|
11
|
+
from torchvision.datasets.folder import IMG_EXTENSIONS
|
|
12
|
+
|
|
13
|
+
ScalarMetadataValue = str | int | float | bool | None
|
|
14
|
+
Metadata = dict[str, ScalarMetadataValue]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class FolderDataset(Dataset):
|
|
18
|
+
"""
|
|
19
|
+
Load images from a (nested) folder.
|
|
20
|
+
Metadata consists of the image's filename as 'image_id' and the parent folder name as 'subset'.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, root: str | Path) -> None:
|
|
24
|
+
self.root = Path(root)
|
|
25
|
+
self.paths = sorted([str(path) for path in self.root.rglob("*") if path.suffix.lower() in IMG_EXTENSIONS])
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def id(self) -> str:
|
|
29
|
+
return self.root.name
|
|
30
|
+
|
|
31
|
+
def __len__(self) -> int:
|
|
32
|
+
return len(self.paths)
|
|
33
|
+
|
|
34
|
+
def __getitem__(self, index: int) -> tuple[Tensor, Metadata]:
|
|
35
|
+
path = self.paths[index]
|
|
36
|
+
with open(path, "rb") as f:
|
|
37
|
+
img = Image.open(f).convert("RGB")
|
|
38
|
+
img = TF.to_dtype(TF.pil_to_tensor(img), dtype=torch.float, scale=True)
|
|
39
|
+
|
|
40
|
+
return img, {"image_id": Path(path).stem, "subset": Path(path).parent.name}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SynthCLIC(Dataset):
|
|
44
|
+
"""
|
|
45
|
+
Load the SynthCLIC dataset (https://huggingface.co/datasets/marco-willi/synthclic).
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
label: Literal["real", "fake"],
|
|
51
|
+
split: Literal["train", "validation", "test", "all"],
|
|
52
|
+
) -> None:
|
|
53
|
+
self.label = label
|
|
54
|
+
self.split = split
|
|
55
|
+
self.ds = load_dataset(path="marco-willi/synthclic", split=split)
|
|
56
|
+
|
|
57
|
+
if label == "real":
|
|
58
|
+
self.ds = self.ds.filter(function=lambda _label: _label == 0, input_columns="label")
|
|
59
|
+
elif label == "fake":
|
|
60
|
+
self.ds = self.ds.filter(function=lambda _label: _label == 1, input_columns="label")
|
|
61
|
+
else:
|
|
62
|
+
raise ValueError(f"Invalid label {label!r}. Must be 'real' or 'fake'.")
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def id(self) -> str:
|
|
66
|
+
if self.label == "real":
|
|
67
|
+
return f"clic2020_{self.split}"
|
|
68
|
+
else:
|
|
69
|
+
return f"synthclic_{self.split}"
|
|
70
|
+
|
|
71
|
+
def __len__(self):
|
|
72
|
+
return len(self.ds)
|
|
73
|
+
|
|
74
|
+
def __getitem__(self, index) -> tuple[Tensor, Metadata]:
|
|
75
|
+
row = self.ds[index]
|
|
76
|
+
image = TF.to_dtype(TF.pil_to_tensor(row["image"].convert("RGB")), dtype=torch.float, scale=True)
|
|
77
|
+
metadata = {"image_id": row["image_id"], "subset": row["source"]}
|
|
78
|
+
|
|
79
|
+
return image, metadata
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class LabelDataset(Dataset):
|
|
83
|
+
"""
|
|
84
|
+
Assign a fixed label to a dataset. Does not pass-through metadata.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
def __init__(self, dataset: Dataset, label: int) -> None:
|
|
88
|
+
self.dataset = dataset
|
|
89
|
+
self.label = label
|
|
90
|
+
|
|
91
|
+
def __len__(self) -> int:
|
|
92
|
+
return len(self.dataset) # type: ignore
|
|
93
|
+
|
|
94
|
+
def __getitem__(self, index) -> tuple[Tensor, int]:
|
|
95
|
+
sample = self.dataset[index]
|
|
96
|
+
if isinstance(sample, tuple):
|
|
97
|
+
sample = self.dataset[index][0]
|
|
98
|
+
return sample, self.label
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class TransformDataset(Dataset):
|
|
102
|
+
"""
|
|
103
|
+
Apply a transform to a dataset. Metadata is preserved.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
def __init__(self, dataset: Dataset, transform: Callable) -> None:
|
|
107
|
+
self.dataset = dataset
|
|
108
|
+
self.transform = transform
|
|
109
|
+
|
|
110
|
+
def __len__(self) -> int:
|
|
111
|
+
return len(self.dataset) # type: ignore
|
|
112
|
+
|
|
113
|
+
def __getitem__(self, index: int) -> tuple[Tensor, Metadata]:
|
|
114
|
+
data = self.dataset[index]
|
|
115
|
+
if isinstance(data, tuple):
|
|
116
|
+
image, metadata = data
|
|
117
|
+
else:
|
|
118
|
+
image = data
|
|
119
|
+
metadata = {}
|
|
120
|
+
return self.transform(image), metadata
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
|
|
3
|
+
from torch import Tensor, nn
|
|
4
|
+
|
|
5
|
+
from biasid.transforms import IdentityTransform
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DetectorWrapper(nn.Module):
|
|
9
|
+
"""
|
|
10
|
+
Wrapper class for detectors.
|
|
11
|
+
Preprocessing is applied to the image before call to 'forward', but after any transformation.
|
|
12
|
+
Postprocessing is applied to the model's outputs.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
model: nn.Module,
|
|
18
|
+
preprocessing: Callable | None = None,
|
|
19
|
+
postprocessing: Callable | None = None,
|
|
20
|
+
) -> None:
|
|
21
|
+
super().__init__()
|
|
22
|
+
|
|
23
|
+
self.model = model
|
|
24
|
+
self.preprocessing = preprocessing if preprocessing is not None else IdentityTransform()
|
|
25
|
+
self.postprocessing = postprocessing if postprocessing is not None else IdentityTransform()
|
|
26
|
+
|
|
27
|
+
self.device = next(self.model.parameters()).device
|
|
28
|
+
self.model.eval()
|
|
29
|
+
|
|
30
|
+
def forward(self, x: Tensor) -> Tensor:
|
|
31
|
+
return self.model(x)
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def id(self) -> str:
|
|
35
|
+
return self.model.__class__.__name__.lower()
|