pad-train 1.0.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.
pad_train/__init__.py ADDED
@@ -0,0 +1,51 @@
1
+ """k-NN presentation attack detection trained on real or synthetic attack samples.
2
+
3
+ Typical use::
4
+
5
+ import pad_train
6
+
7
+ pad_train.settings.data_root = "/path/to/data" # optional
8
+ results = pad_train.evaluate(extractors=["fourier", "haralick"]) # pandas.DataFrame
9
+ """
10
+
11
+ from importlib.metadata import PackageNotFoundError, version
12
+
13
+ from pad_train.config import DatasetConfig, DataSource, Settings, settings
14
+ from pad_train.data import PadData, download_data, load_data
15
+ from pad_train.evaluation import cross_validate, evaluate, evaluate_dataset
16
+ from pad_train.features import (
17
+ FeatureExtractor,
18
+ available_extractors,
19
+ get_extractor,
20
+ make_fourier_extractor,
21
+ register_dataset_extractor,
22
+ register_extractor,
23
+ )
24
+ from pad_train.metrics import PadMetrics, pad_metrics
25
+
26
+ try:
27
+ __version__: str = version("pad_train")
28
+ except PackageNotFoundError: # running from a source tree without installation
29
+ __version__ = "0.0.0"
30
+
31
+ __all__ = [
32
+ "DataSource",
33
+ "DatasetConfig",
34
+ "FeatureExtractor",
35
+ "PadData",
36
+ "PadMetrics",
37
+ "Settings",
38
+ "__version__",
39
+ "available_extractors",
40
+ "cross_validate",
41
+ "download_data",
42
+ "evaluate",
43
+ "evaluate_dataset",
44
+ "get_extractor",
45
+ "load_data",
46
+ "make_fourier_extractor",
47
+ "pad_metrics",
48
+ "register_dataset_extractor",
49
+ "register_extractor",
50
+ "settings",
51
+ ]
pad_train/cli.py ADDED
@@ -0,0 +1,66 @@
1
+ """Command line interface: ``pad-train download`` and ``pad-train evaluate``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import logging
7
+ from collections.abc import Sequence
8
+ from pathlib import Path
9
+
10
+ from pad_train.config import settings
11
+ from pad_train.data import download_data
12
+ from pad_train.evaluation import PROTOCOLS, evaluate
13
+ from pad_train.features import available_extractors
14
+
15
+
16
+ def _build_parser() -> argparse.ArgumentParser:
17
+ """Create the argument parser."""
18
+ parser = argparse.ArgumentParser(prog="pad-train", description=__doc__)
19
+ parser.add_argument(
20
+ "--data-root", type=Path, help=f"data directory (default: {settings.data_root})"
21
+ )
22
+ commands = parser.add_subparsers(dest="command", required=True)
23
+
24
+ commands.add_parser("download", help="fetch all datasets that are not on disk yet")
25
+
26
+ run = commands.add_parser("evaluate", help="run the evaluation and print the result table")
27
+ run.add_argument("--datasets", nargs="+", choices=list(settings.datasets), help="default: all")
28
+ run.add_argument("--extractors", nargs="+", default=["fourier"], choices=available_extractors())
29
+ run.add_argument(
30
+ "--synthetic", nargs="+", choices=list(settings.synthetic_sources), help="default: all"
31
+ )
32
+ run.add_argument("--protocols", nargs="+", default=list(PROTOCOLS), choices=PROTOCOLS)
33
+ run.add_argument("--n-neighbors", type=int, help="k of the k-NN (default: per dataset)")
34
+ run.add_argument("--per-fold", action="store_true", help="report every fold, not the mean")
35
+ run.add_argument("--no-download", action="store_true", help="fail if data is missing")
36
+ run.add_argument("--output", type=Path, help="also write the table to this CSV file")
37
+ return parser
38
+
39
+
40
+ def main(argv: Sequence[str] | None = None) -> None:
41
+ """Run the command line interface."""
42
+ args = _build_parser().parse_args(argv)
43
+ logging.basicConfig(level=logging.INFO, format="%(message)s")
44
+ if args.data_root is not None:
45
+ settings.data_root = args.data_root
46
+
47
+ if args.command == "download":
48
+ download_data()
49
+ return
50
+
51
+ results = evaluate(
52
+ datasets=args.datasets,
53
+ extractors=args.extractors,
54
+ synthetic=args.synthetic,
55
+ protocols=args.protocols,
56
+ n_neighbors=args.n_neighbors,
57
+ per_fold=args.per_fold,
58
+ download=not args.no_download,
59
+ )
60
+ print(results.to_string(index=False, float_format="{:.4f}".format))
61
+ if args.output is not None:
62
+ results.to_csv(args.output, index=False)
63
+
64
+
65
+ if __name__ == "__main__":
66
+ main()
pad_train/config.py ADDED
@@ -0,0 +1,172 @@
1
+ """Central configuration: where the data lives and how the datasets are laid out.
2
+
3
+ Everything is plain Python. To change a value, mutate the module-level
4
+ :data:`settings` object before calling into the package::
5
+
6
+ from pathlib import Path
7
+ from pad_train import settings
8
+
9
+ settings.data_root = Path("/mnt/datasets/finger_vein")
10
+
11
+ The data root can also be set through the ``PAD_TRAIN_DATA_ROOT`` environment
12
+ variable, which is read once at import time.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ from dataclasses import dataclass, field
19
+ from pathlib import Path
20
+
21
+ #: Environment variable overriding the default data root.
22
+ DATA_ROOT_ENV_VAR: str = "PAD_TRAIN_DATA_ROOT"
23
+
24
+
25
+ @dataclass
26
+ class DataSource:
27
+ """A directory of samples, optionally backed by a downloadable zip archive.
28
+
29
+ Attributes:
30
+ name: Short identifier, used in logs and in the result table.
31
+ path: Directory holding the per-dataset folders. Relative paths are
32
+ resolved against :attr:`Settings.data_root`.
33
+ url: Zip archive to fetch when ``path`` does not exist. ``None`` marks a
34
+ purely local source (e.g. samples produced by your own code).
35
+ archive_root: Folder inside the archive that holds the per-dataset
36
+ folders. ``None`` if they sit at the top level of the archive.
37
+ """
38
+
39
+ name: str
40
+ path: Path
41
+ url: str | None = None
42
+ archive_root: str | None = None
43
+
44
+
45
+ @dataclass
46
+ class DatasetConfig:
47
+ """Layout and classifier settings of one finger vein dataset.
48
+
49
+ Attributes:
50
+ name: Dataset identifier (``"PLUS"``, ``"IDIAP"``, ``"SCUT"``).
51
+ real_dir: Dataset folder inside the real/spoof source.
52
+ synthetic_dir: Dataset folder inside a synthetic source.
53
+ subsets: Sub-folders of ``real_dir`` that each contain a ``real`` and a
54
+ ``spoof`` folder. ``("",)`` means they sit directly in ``real_dir``.
55
+ n_neighbors: ``k`` of the k-NN classifier.
56
+ pair_by_filename: Real and spoof captures of the same finger share a
57
+ file name, so bona fide samples can be matched to their attack
58
+ sample one-to-one. If ``False`` the matching is done per subject.
59
+ fourier_thirds: Thirds of the Fourier magnitude spectrum used by the
60
+ ``fourier`` extractor for this dataset: 1 = low, 2 = middle,
61
+ 3 = high frequencies. Change them in :func:`_default_datasets`.
62
+ """
63
+
64
+ name: str
65
+ real_dir: str
66
+ synthetic_dir: str
67
+ subsets: tuple[str, ...] = ("",)
68
+ n_neighbors: int = 1
69
+ pair_by_filename: bool = False
70
+ fourier_thirds: tuple[int, ...] = (1, 2, 3)
71
+
72
+
73
+ def _default_data_root() -> Path:
74
+ """Return the data root from the environment, falling back to ``./data``."""
75
+ return Path(os.environ.get(DATA_ROOT_ENV_VAR, "data")).expanduser()
76
+
77
+
78
+ def _default_synthetic_sources() -> dict[str, DataSource]:
79
+ """Return the two diffusion-based synthetic attack sources."""
80
+ return {
81
+ "AE_Res": DataSource(
82
+ name="AE_Res",
83
+ path=Path("AE_Res-diff"),
84
+ url="https://cosy.sbg.ac.at/~uhl/AE_Res-diff.zip",
85
+ ),
86
+ "VAE": DataSource(
87
+ name="VAE",
88
+ path=Path("VAE-diff"),
89
+ url="https://cosy.sbg.ac.at/~uhl/VAE-diff.zip",
90
+ ),
91
+ }
92
+
93
+
94
+ def _default_datasets() -> dict[str, DatasetConfig]:
95
+ """Return the layout of the three supported datasets."""
96
+ return {
97
+ "PLUS": DatasetConfig(
98
+ name="PLUS",
99
+ real_dir="PLUS",
100
+ synthetic_dir="PLUS_matched",
101
+ n_neighbors=6,
102
+ pair_by_filename=True,
103
+ fourier_thirds=(1, 2, 3),
104
+ ),
105
+ "IDIAP": DatasetConfig(
106
+ name="IDIAP",
107
+ real_dir="IDIAP/full",
108
+ synthetic_dir="IDIAP",
109
+ # ``test`` is unlabelled in the archive and therefore unusable.
110
+ subsets=("train", "dev"),
111
+ pair_by_filename=True,
112
+ fourier_thirds=(1, 2),
113
+ ),
114
+ "SCUT": DatasetConfig(
115
+ name="SCUT",
116
+ real_dir="SCUT/full",
117
+ synthetic_dir="SCUT",
118
+ subsets=("train", "dev", "test"),
119
+ fourier_thirds=(1,),
120
+ ),
121
+ }
122
+
123
+
124
+ @dataclass
125
+ class Settings:
126
+ """All tunable values of the package.
127
+
128
+ Attributes:
129
+ data_root: Base directory for every relative :attr:`DataSource.path`.
130
+ real_source: Source of the bona fide (type 1) and real attack (type 2)
131
+ samples.
132
+ synthetic_sources: Sources of synthetic attack (type 3) samples, keyed
133
+ by name. Add an entry to evaluate another generator.
134
+ synthetic_attack_subdir: Folder below a synthetic dataset folder that
135
+ holds the attack samples.
136
+ datasets: Supported datasets, keyed by name.
137
+ n_folds: Number of subject-disjoint cross-validation folds.
138
+ random_state: Seed for every random choice (synthetic sub-sampling).
139
+ """
140
+
141
+ data_root: Path = field(default_factory=_default_data_root)
142
+ real_source: DataSource = field(
143
+ default_factory=lambda: DataSource(
144
+ name="real",
145
+ path=Path("Data_FV_Spoofing_WS2025_26"),
146
+ url="https://www.cosy.sbg.ac.at/~uhl/Data_FV_Spoofing_WS2025_26.zip",
147
+ archive_root="Data_FV_Spoofing_WS2025_26",
148
+ )
149
+ )
150
+ synthetic_sources: dict[str, DataSource] = field(default_factory=_default_synthetic_sources)
151
+ synthetic_attack_subdir: str = "spoof/samples"
152
+ datasets: dict[str, DatasetConfig] = field(default_factory=_default_datasets)
153
+ n_folds: int = 5
154
+ random_state: int = 42
155
+
156
+ def resolve(self, source: DataSource) -> Path:
157
+ """Return the absolute-or-root-relative directory of ``source``."""
158
+ path = Path(source.path).expanduser()
159
+ return path if path.is_absolute() else Path(self.data_root).expanduser() / path
160
+
161
+ def synthetic_folder(self, source: str, dataset: str) -> Path:
162
+ """Return the folder holding the attack samples of ``dataset`` in ``source``."""
163
+ root = self.resolve(self.synthetic_sources[source])
164
+ return root / self.datasets[dataset].synthetic_dir / self.synthetic_attack_subdir
165
+
166
+ def sources(self) -> list[DataSource]:
167
+ """Return every configured source, the real one first."""
168
+ return [self.real_source, *self.synthetic_sources.values()]
169
+
170
+
171
+ #: Package-wide default settings. Mutate this object to reconfigure the package.
172
+ settings: Settings = Settings()
pad_train/data.py ADDED
@@ -0,0 +1,205 @@
1
+ """Dataset download and loading."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import shutil
7
+ import tempfile
8
+ import urllib.request
9
+ import zipfile
10
+ from collections.abc import Sequence
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+
14
+ import numpy as np
15
+ from numpy.typing import NDArray
16
+ from PIL import Image as PILImage
17
+
18
+ from pad_train.config import DataSource, Settings, settings
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ #: A grayscale image as a 2-D ``uint8`` array.
23
+ Image = NDArray[np.uint8]
24
+
25
+ #: File suffixes (lower case) that are read as images.
26
+ IMAGE_SUFFIXES: frozenset[str] = frozenset({".png", ".bmp", ".jpg", ".jpeg"})
27
+
28
+ #: Bytes read per chunk while downloading.
29
+ _CHUNK_SIZE: int = 1 << 20
30
+
31
+
32
+ @dataclass
33
+ class PadData:
34
+ """All samples of one dataset, ready for evaluation.
35
+
36
+ The bona fide and attack sets are balanced: only bona fide samples with a
37
+ matching real attack sample are kept (and vice versa).
38
+
39
+ Attributes:
40
+ dataset: Dataset name.
41
+ real: Bona fide (type 1) images.
42
+ real_subjects: Subject id of every bona fide image.
43
+ spoof: Real presentation attack (type 2) images.
44
+ spoof_subjects: Subject id of every attack image.
45
+ synthetic: Synthetic attack (type 3) images, keyed by source name. They
46
+ carry no subject identity.
47
+ """
48
+
49
+ dataset: str
50
+ real: list[Image]
51
+ real_subjects: NDArray[np.str_]
52
+ spoof: list[Image]
53
+ spoof_subjects: NDArray[np.str_]
54
+ synthetic: dict[str, list[Image]] = field(default_factory=dict)
55
+
56
+
57
+ def _download(url: str, target: Path) -> None:
58
+ """Stream ``url`` to ``target``, going through a ``.part`` file."""
59
+ partial = target.with_name(target.name + ".part")
60
+ logger.info("Downloading %s", url)
61
+ with urllib.request.urlopen(url) as response, partial.open("wb") as out:
62
+ total = int(response.headers.get("Content-Length") or 0)
63
+ done, next_report = 0, 0.1
64
+ while chunk := response.read(_CHUNK_SIZE):
65
+ out.write(chunk)
66
+ done += len(chunk)
67
+ if total and done / total >= next_report:
68
+ logger.info(" %3.0f %% of %.0f MB", 100 * done / total, total / 1e6)
69
+ next_report += 0.1
70
+ partial.replace(target)
71
+
72
+
73
+ def _extract(archive: Path, target: Path, archive_root: str | None) -> None:
74
+ """Extract ``archive`` so that ``archive_root`` (or its top level) becomes ``target``.
75
+
76
+ Extraction happens in a temporary sibling directory which is moved into
77
+ place at the end, so an existing ``target`` is always complete.
78
+ """
79
+ logger.info("Extracting %s", archive.name)
80
+ with tempfile.TemporaryDirectory(dir=target.parent, prefix=f".{target.name}-") as tmp:
81
+ staging = Path(tmp) / "content"
82
+ with zipfile.ZipFile(archive) as zip_file:
83
+ zip_file.extractall(staging)
84
+ payload = staging / archive_root if archive_root else staging
85
+ if not payload.is_dir():
86
+ raise FileNotFoundError(f"'{archive_root}' not found in {archive.name}")
87
+ shutil.move(payload, target)
88
+
89
+
90
+ def ensure_source(
91
+ source: DataSource, download: bool = True, config: Settings | None = None
92
+ ) -> Path:
93
+ """Return the directory of ``source``, fetching it first if it is missing.
94
+
95
+ Args:
96
+ source: The data source to locate.
97
+ download: Download and extract the archive if the directory is missing.
98
+ config: Settings to use instead of the package-wide ones.
99
+
100
+ Returns:
101
+ The existing directory of the source.
102
+
103
+ Raises:
104
+ FileNotFoundError: The directory is missing and could not be fetched.
105
+ """
106
+ config = config or settings
107
+ path = config.resolve(source)
108
+ if path.is_dir():
109
+ return path
110
+ if not download or source.url is None:
111
+ raise FileNotFoundError(
112
+ f"Data source '{source.name}' not found at {path}. "
113
+ "Run `pad-train download` or call load_data(download=True)."
114
+ )
115
+
116
+ path.parent.mkdir(parents=True, exist_ok=True)
117
+ archive = path.parent / Path(source.url).name
118
+ if not archive.is_file():
119
+ _download(source.url, archive)
120
+ _extract(archive, path, source.archive_root)
121
+ archive.unlink()
122
+ return path
123
+
124
+
125
+ def download_data(config: Settings | None = None) -> None:
126
+ """Fetch every configured data source that is not on disk yet."""
127
+ config = config or settings
128
+ for source in config.sources():
129
+ if source.url is not None:
130
+ logger.info("%s -> %s", source.name, ensure_source(source, config=config))
131
+
132
+
133
+ def image_files(folder: Path) -> list[Path]:
134
+ """Return all image files below ``folder`` in a stable order."""
135
+ return sorted(p for p in folder.rglob("*") if p.suffix.lower() in IMAGE_SUFFIXES)
136
+
137
+
138
+ def read_image(path: Path) -> Image:
139
+ """Read an image file as a grayscale array."""
140
+ with PILImage.open(path) as image:
141
+ return np.asarray(image.convert("L"), dtype=np.uint8)
142
+
143
+
144
+ def load_data(
145
+ dataset: str,
146
+ synthetic: Sequence[str] | None = None,
147
+ download: bool = True,
148
+ config: Settings | None = None,
149
+ ) -> PadData:
150
+ """Load one dataset, downloading the underlying archives when necessary.
151
+
152
+ Args:
153
+ dataset: Dataset name, a key of :attr:`Settings.datasets`.
154
+ synthetic: Names of the synthetic sources to load. ``None`` loads all
155
+ configured ones, an empty sequence loads none.
156
+ download: Fetch missing sources. If ``False`` a missing source raises.
157
+ config: Settings to use instead of the package-wide ones.
158
+
159
+ Returns:
160
+ The balanced bona fide / attack samples plus the synthetic samples.
161
+
162
+ Raises:
163
+ KeyError: Unknown dataset or synthetic source name.
164
+ FileNotFoundError: A source is missing (and ``download`` is ``False``),
165
+ or it contains no samples for this dataset.
166
+ """
167
+ config = config or settings
168
+ layout = config.datasets[dataset]
169
+ real_root = ensure_source(config.real_source, download, config) / layout.real_dir
170
+
171
+ # Subject = name of the folder a capture lives in; key = what makes a bona
172
+ # fide sample and an attack sample "belong together".
173
+ files: dict[str, list[Path]] = {"real": [], "spoof": []}
174
+ for subset in layout.subsets:
175
+ for kind, found in files.items():
176
+ found.extend(image_files(real_root / subset / kind))
177
+
178
+ def key(path: Path) -> tuple[str, ...]:
179
+ subject = path.parent.name
180
+ return (subject, path.name) if layout.pair_by_filename else (subject,)
181
+
182
+ # Balanced sets: drop everything without a counterpart in the other class.
183
+ common = {key(p) for p in files["real"]} & {key(p) for p in files["spoof"]}
184
+ real_files = [p for p in files["real"] if key(p) in common]
185
+ spoof_files = [p for p in files["spoof"] if key(p) in common]
186
+ if not real_files:
187
+ raise FileNotFoundError(f"No matching real/spoof samples for {dataset} in {real_root}")
188
+
189
+ names = list(config.synthetic_sources) if synthetic is None else list(synthetic)
190
+ synthetic_images: dict[str, list[Image]] = {}
191
+ for name in names:
192
+ ensure_source(config.synthetic_sources[name], download, config)
193
+ folder = config.synthetic_folder(name, dataset)
194
+ synthetic_images[name] = [read_image(p) for p in image_files(folder)]
195
+ if not synthetic_images[name]:
196
+ raise FileNotFoundError(f"No synthetic samples for {dataset} in {folder}")
197
+
198
+ return PadData(
199
+ dataset=dataset,
200
+ real=[read_image(p) for p in real_files],
201
+ real_subjects=np.array([p.parent.name for p in real_files]),
202
+ spoof=[read_image(p) for p in spoof_files],
203
+ spoof_subjects=np.array([p.parent.name for p in spoof_files]),
204
+ synthetic=synthetic_images,
205
+ )