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 +51 -0
- pad_train/cli.py +66 -0
- pad_train/config.py +172 -0
- pad_train/data.py +205 -0
- pad_train/evaluation.py +221 -0
- pad_train/features/__init__.py +28 -0
- pad_train/features/fourier.py +123 -0
- pad_train/features/haralick.py +29 -0
- pad_train/features/registry.py +92 -0
- pad_train/metrics.py +46 -0
- pad_train/py.typed +0 -0
- pad_train-1.0.0.dist-info/METADATA +301 -0
- pad_train-1.0.0.dist-info/RECORD +16 -0
- pad_train-1.0.0.dist-info/WHEEL +5 -0
- pad_train-1.0.0.dist-info/entry_points.txt +2 -0
- pad_train-1.0.0.dist-info/top_level.txt +1 -0
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
|
+
)
|