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 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,4 @@
1
+ from biasid.core import get_score_df as get_score_df
2
+ from biasid.detector_wrapper import DetectorWrapper as DetectorWrapper
3
+ from biasid.metrics import compute_metrics as compute_metrics
4
+ from biasid.visualization import create_bias_plot as create_bias_plot
@@ -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()