cnn-classifier-toolkit 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.
- cnn_classifier/__init__.py +5 -0
- cnn_classifier/_version.py +1 -0
- cnn_classifier/cli.py +1085 -0
- cnn_classifier/config/__init__.py +17 -0
- cnn_classifier/config/loader.py +102 -0
- cnn_classifier/config/presets/default.yaml +47 -0
- cnn_classifier/config/resolve.py +108 -0
- cnn_classifier/config/schema.py +251 -0
- cnn_classifier/core/__init__.py +19 -0
- cnn_classifier/core/device.py +50 -0
- cnn_classifier/core/errors.py +21 -0
- cnn_classifier/core/hashing.py +18 -0
- cnn_classifier/core/registry.py +46 -0
- cnn_classifier/core/seeding.py +37 -0
- cnn_classifier/core/types.py +97 -0
- cnn_classifier/data/__init__.py +22 -0
- cnn_classifier/data/adapters/__init__.py +19 -0
- cnn_classifier/data/adapters/_common.py +241 -0
- cnn_classifier/data/adapters/image_folder.py +65 -0
- cnn_classifier/data/adapters/manifest.py +62 -0
- cnn_classifier/data/adapters/numpy.py +96 -0
- cnn_classifier/data/adapters/registry.py +29 -0
- cnn_classifier/data/adapters/synthetic.py +114 -0
- cnn_classifier/data/adapters/torchvision.py +192 -0
- cnn_classifier/data/base.py +17 -0
- cnn_classifier/data/contract.py +28 -0
- cnn_classifier/data/image_io.py +116 -0
- cnn_classifier/data/inspection.py +111 -0
- cnn_classifier/data/loaders.py +79 -0
- cnn_classifier/data/manifest.py +98 -0
- cnn_classifier/data/splits.py +136 -0
- cnn_classifier/data/stats.py +106 -0
- cnn_classifier/data/transforms.py +192 -0
- cnn_classifier/diagnostics/__init__.py +22 -0
- cnn_classifier/diagnostics/activations.py +56 -0
- cnn_classifier/diagnostics/architecture.py +172 -0
- cnn_classifier/diagnostics/determinism.py +25 -0
- cnn_classifier/diagnostics/gradients.py +32 -0
- cnn_classifier/diagnostics/sanity.py +49 -0
- cnn_classifier/diagnostics/tracing.py +56 -0
- cnn_classifier/evaluation/__init__.py +53 -0
- cnn_classifier/evaluation/bootstrap.py +96 -0
- cnn_classifier/evaluation/calibration.py +74 -0
- cnn_classifier/evaluation/compare.py +111 -0
- cnn_classifier/evaluation/error_analysis.py +61 -0
- cnn_classifier/evaluation/evaluator.py +143 -0
- cnn_classifier/evaluation/latency.py +58 -0
- cnn_classifier/evaluation/metrics.py +94 -0
- cnn_classifier/evaluation/predict.py +64 -0
- cnn_classifier/models/__init__.py +50 -0
- cnn_classifier/models/base.py +62 -0
- cnn_classifier/models/residual_cnn.py +138 -0
- cnn_classifier/models/simple_cnn.py +55 -0
- cnn_classifier/models/summary.py +39 -0
- cnn_classifier/models/torchvision.py +46 -0
- cnn_classifier/models/transfer.py +193 -0
- cnn_classifier/runs/__init__.py +6 -0
- cnn_classifier/runs/artifacts.py +50 -0
- cnn_classifier/runs/context.py +130 -0
- cnn_classifier/tracking/__init__.py +34 -0
- cnn_classifier/tracking/base.py +16 -0
- cnn_classifier/tracking/composite.py +20 -0
- cnn_classifier/tracking/console.py +14 -0
- cnn_classifier/tracking/csv.py +19 -0
- cnn_classifier/tracking/tensorboard.py +22 -0
- cnn_classifier/training/__init__.py +37 -0
- cnn_classifier/training/amp.py +51 -0
- cnn_classifier/training/callbacks.py +229 -0
- cnn_classifier/training/checkpoint.py +151 -0
- cnn_classifier/training/losses.py +67 -0
- cnn_classifier/training/optim.py +40 -0
- cnn_classifier/training/schedulers.py +53 -0
- cnn_classifier/training/state.py +35 -0
- cnn_classifier/training/trainer.py +357 -0
- cnn_classifier/viz/__init__.py +42 -0
- cnn_classifier/viz/architecture.py +276 -0
- cnn_classifier/viz/compare.py +36 -0
- cnn_classifier/viz/data.py +56 -0
- cnn_classifier/viz/evaluation.py +134 -0
- cnn_classifier/viz/features.py +122 -0
- cnn_classifier/viz/report.py +821 -0
- cnn_classifier/viz/style.py +69 -0
- cnn_classifier/viz/templates/report.html.j2 +355 -0
- cnn_classifier/viz/training.py +83 -0
- cnn_classifier_toolkit-0.1.0.dist-info/METADATA +249 -0
- cnn_classifier_toolkit-0.1.0.dist-info/RECORD +89 -0
- cnn_classifier_toolkit-0.1.0.dist-info/WHEEL +4 -0
- cnn_classifier_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
- cnn_classifier_toolkit-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Deterministic seeding helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import random
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import torch
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def seed_everything(seed: int, deterministic: bool = True) -> None:
|
|
13
|
+
"""Seed Python, NumPy, and PyTorch, and request deterministic kernels.
|
|
14
|
+
|
|
15
|
+
MPS does not guarantee bit-identical results across versions or devices even
|
|
16
|
+
with a fixed seed; this function makes runs repeatable within one environment
|
|
17
|
+
as far as the backend allows.
|
|
18
|
+
"""
|
|
19
|
+
random.seed(seed)
|
|
20
|
+
np.random.seed(seed)
|
|
21
|
+
torch.manual_seed(seed)
|
|
22
|
+
if torch.cuda.is_available():
|
|
23
|
+
torch.cuda.manual_seed_all(seed)
|
|
24
|
+
if deterministic:
|
|
25
|
+
torch.backends.cudnn.deterministic = True
|
|
26
|
+
torch.backends.cudnn.benchmark = False
|
|
27
|
+
with contextlib.suppress(RuntimeError):
|
|
28
|
+
# Some backends do not expose a deterministic implementation for
|
|
29
|
+
# every op. The run metadata records the requested policy.
|
|
30
|
+
torch.use_deterministic_algorithms(True, warn_only=True)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def make_generator(seed: int) -> torch.Generator:
|
|
34
|
+
"""Return a seeded CPU ``torch.Generator``."""
|
|
35
|
+
generator = torch.Generator()
|
|
36
|
+
generator.manual_seed(seed)
|
|
37
|
+
return generator
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Shared data contracts exchanged between adapters, models, and runs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Literal
|
|
8
|
+
|
|
9
|
+
from torch.utils.data import Dataset
|
|
10
|
+
|
|
11
|
+
from cnn_classifier.core.errors import DataError
|
|
12
|
+
|
|
13
|
+
DatasetLike = Dataset[Any]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class SampleRecord:
|
|
18
|
+
path: Path
|
|
19
|
+
label: str
|
|
20
|
+
split: Literal["train", "val", "test"]
|
|
21
|
+
group: str | None = None
|
|
22
|
+
severity: float | None = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class SampleMetadata:
|
|
27
|
+
paths: tuple[Path, ...]
|
|
28
|
+
labels: tuple[int, ...]
|
|
29
|
+
groups: tuple[str | None, ...]
|
|
30
|
+
source_indices: tuple[int, ...]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class NormalizationStats:
|
|
35
|
+
mean: tuple[float, ...]
|
|
36
|
+
std: tuple[float, ...]
|
|
37
|
+
source: Literal["computed", "supplied", "none"]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class ModelInputContract:
|
|
42
|
+
input_shape: tuple[int, int, int]
|
|
43
|
+
dtype: Literal["float32"]
|
|
44
|
+
value_range: tuple[float, float]
|
|
45
|
+
normalization: NormalizationStats | None
|
|
46
|
+
class_names: tuple[str, ...]
|
|
47
|
+
label_map: dict[str, int]
|
|
48
|
+
channel_policy: Literal["rgb", "gray", "as_is"]
|
|
49
|
+
stem_adaptation: str | None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class SplitInfo:
|
|
54
|
+
mode: Literal["random", "random_stratified", "group", "explicit", "train_only"]
|
|
55
|
+
train_indices: tuple[int, ...]
|
|
56
|
+
val_indices: tuple[int, ...]
|
|
57
|
+
test_indices: tuple[int, ...] | None
|
|
58
|
+
seed: int
|
|
59
|
+
group_column: str | None
|
|
60
|
+
fingerprint: str
|
|
61
|
+
|
|
62
|
+
def __post_init__(self) -> None:
|
|
63
|
+
all_indices = [*self.train_indices, *self.val_indices]
|
|
64
|
+
if self.test_indices is not None:
|
|
65
|
+
all_indices.extend(self.test_indices)
|
|
66
|
+
if len(all_indices) != len(set(all_indices)):
|
|
67
|
+
raise DataError("train, val, and test indices overlap")
|
|
68
|
+
if any(index < 0 for index in all_indices):
|
|
69
|
+
raise DataError("split indices must be non-negative")
|
|
70
|
+
if not self.fingerprint:
|
|
71
|
+
raise DataError("split fingerprint must not be empty")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class DatasetBundle:
|
|
76
|
+
train: DatasetLike
|
|
77
|
+
val: DatasetLike
|
|
78
|
+
test: DatasetLike | None
|
|
79
|
+
class_names: tuple[str, ...]
|
|
80
|
+
in_channels: int
|
|
81
|
+
image_size: tuple[int, int]
|
|
82
|
+
normalization: NormalizationStats | None
|
|
83
|
+
split_info: SplitInfo
|
|
84
|
+
fingerprint: str
|
|
85
|
+
sample_metadata: SampleMetadata
|
|
86
|
+
model_contract: ModelInputContract
|
|
87
|
+
test_fingerprint: str | None = None
|
|
88
|
+
|
|
89
|
+
def __post_init__(self) -> None:
|
|
90
|
+
if not self.class_names:
|
|
91
|
+
raise DataError("class_names must not be empty")
|
|
92
|
+
if len(set(self.class_names)) != len(self.class_names):
|
|
93
|
+
raise DataError("class_names must be unique")
|
|
94
|
+
if self.in_channels <= 0:
|
|
95
|
+
raise DataError("in_channels must be positive")
|
|
96
|
+
if self.image_size[0] <= 0 or self.image_size[1] <= 0:
|
|
97
|
+
raise DataError("image_size must be positive")
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Reusable data layer for dataset adapters, splits, and image contracts."""
|
|
2
|
+
|
|
3
|
+
from cnn_classifier.data.base import DatasetAdapter
|
|
4
|
+
from cnn_classifier.data.manifest import Manifest, fingerprint_records
|
|
5
|
+
from cnn_classifier.data.splits import (
|
|
6
|
+
SplitIndices,
|
|
7
|
+
assert_no_group_overlap,
|
|
8
|
+
group_split_indices,
|
|
9
|
+
random_split_indices,
|
|
10
|
+
stratified_split_indices,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"DatasetAdapter",
|
|
15
|
+
"Manifest",
|
|
16
|
+
"SplitIndices",
|
|
17
|
+
"assert_no_group_overlap",
|
|
18
|
+
"fingerprint_records",
|
|
19
|
+
"group_split_indices",
|
|
20
|
+
"random_split_indices",
|
|
21
|
+
"stratified_split_indices",
|
|
22
|
+
]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Dataset adapter registry and public builders."""
|
|
2
|
+
|
|
3
|
+
from cnn_classifier.data.adapters.image_folder import ImageFolderAdapter
|
|
4
|
+
from cnn_classifier.data.adapters.manifest import ManifestAdapter
|
|
5
|
+
from cnn_classifier.data.adapters.numpy import NumpyAdapter
|
|
6
|
+
from cnn_classifier.data.adapters.registry import DATASETS, build_dataset, register_dataset
|
|
7
|
+
from cnn_classifier.data.adapters.synthetic import SyntheticAdapter
|
|
8
|
+
from cnn_classifier.data.adapters.torchvision import TorchvisionAdapter
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"DATASETS",
|
|
12
|
+
"ImageFolderAdapter",
|
|
13
|
+
"ManifestAdapter",
|
|
14
|
+
"NumpyAdapter",
|
|
15
|
+
"SyntheticAdapter",
|
|
16
|
+
"TorchvisionAdapter",
|
|
17
|
+
"build_dataset",
|
|
18
|
+
"register_dataset",
|
|
19
|
+
]
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Shared dataset construction helpers used by the concrete adapters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Literal
|
|
9
|
+
|
|
10
|
+
import torch
|
|
11
|
+
from torch.utils.data import Dataset, Subset
|
|
12
|
+
|
|
13
|
+
from cnn_classifier.config.schema import DataConfig
|
|
14
|
+
from cnn_classifier.core.errors import ConfigError, DataError
|
|
15
|
+
from cnn_classifier.core.hashing import stable_hash
|
|
16
|
+
from cnn_classifier.core.types import (
|
|
17
|
+
DatasetBundle,
|
|
18
|
+
ModelInputContract,
|
|
19
|
+
NormalizationStats,
|
|
20
|
+
SampleMetadata,
|
|
21
|
+
SplitInfo,
|
|
22
|
+
)
|
|
23
|
+
from cnn_classifier.data.image_io import ChannelPolicy, read_image
|
|
24
|
+
from cnn_classifier.data.splits import (
|
|
25
|
+
SplitIndices,
|
|
26
|
+
assert_no_group_overlap,
|
|
27
|
+
group_split_indices,
|
|
28
|
+
random_split_indices,
|
|
29
|
+
stratified_split_indices,
|
|
30
|
+
)
|
|
31
|
+
from cnn_classifier.data.stats import compute_normalization
|
|
32
|
+
from cnn_classifier.data.transforms import EnsureTensor, build_transforms
|
|
33
|
+
|
|
34
|
+
SplitName = Literal["train", "val", "test"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class RawSample:
|
|
39
|
+
"""A source image (path or tensor) and its logical label/split."""
|
|
40
|
+
|
|
41
|
+
source: Any
|
|
42
|
+
label: str
|
|
43
|
+
split: SplitName
|
|
44
|
+
group: str | None = None
|
|
45
|
+
severity: float | None = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class RawDataset(Dataset[tuple[torch.Tensor, int, int]]):
|
|
49
|
+
"""Apply label mapping, image decoding, and an optional transform."""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
samples: Sequence[RawSample],
|
|
54
|
+
label_to_index: dict[str, int],
|
|
55
|
+
channels: ChannelPolicy,
|
|
56
|
+
value_range: Any,
|
|
57
|
+
transform: Any = None,
|
|
58
|
+
) -> None:
|
|
59
|
+
self.samples = tuple(samples)
|
|
60
|
+
self.label_to_index = label_to_index
|
|
61
|
+
self.channels = channels
|
|
62
|
+
self.value_range = value_range
|
|
63
|
+
self.transform = transform
|
|
64
|
+
|
|
65
|
+
def __len__(self) -> int:
|
|
66
|
+
return len(self.samples)
|
|
67
|
+
|
|
68
|
+
def __getitem__(self, index: int) -> tuple[torch.Tensor, int, int]:
|
|
69
|
+
sample = self.samples[index]
|
|
70
|
+
if isinstance(sample.source, Path):
|
|
71
|
+
image = read_image(sample.source, channels=self.channels, value_range=self.value_range)
|
|
72
|
+
elif torch.is_tensor(sample.source):
|
|
73
|
+
image = sample.source.to(torch.float32)
|
|
74
|
+
else:
|
|
75
|
+
image = EnsureTensor()(sample.source)
|
|
76
|
+
if self.transform is not None:
|
|
77
|
+
image = self.transform(image)
|
|
78
|
+
return image, self.label_to_index[sample.label], index
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def resolve_class_names(
|
|
82
|
+
labels: Sequence[str],
|
|
83
|
+
config: DataConfig,
|
|
84
|
+
) -> tuple[str, ...]:
|
|
85
|
+
"""Resolve explicit class names or derive a stable sorted order."""
|
|
86
|
+
if isinstance(config.class_names, tuple):
|
|
87
|
+
names = config.class_names
|
|
88
|
+
if config.num_classes is not None and len(names) != config.num_classes:
|
|
89
|
+
raise ConfigError("data.class_names length must match data.num_classes")
|
|
90
|
+
missing = set(labels) - set(names)
|
|
91
|
+
if missing:
|
|
92
|
+
raise ConfigError(f"data.class_names is missing labels: {sorted(missing)}")
|
|
93
|
+
return names
|
|
94
|
+
return tuple(sorted(set(labels)))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def assign_split(samples: Sequence[RawSample], config: DataConfig) -> tuple[SplitIndices, str]:
|
|
98
|
+
"""Resolve the configured split policy into source indices."""
|
|
99
|
+
mode = config.split.mode
|
|
100
|
+
if mode == "explicit":
|
|
101
|
+
train = tuple(i for i, sample in enumerate(samples) if sample.split == "train")
|
|
102
|
+
val = tuple(i for i, sample in enumerate(samples) if sample.split == "val")
|
|
103
|
+
test = tuple(i for i, sample in enumerate(samples) if sample.split == "test")
|
|
104
|
+
return SplitIndices(train, val, test or None), "explicit"
|
|
105
|
+
labels = [sample.label for sample in samples]
|
|
106
|
+
if mode == "random":
|
|
107
|
+
return random_split_indices(labels, config.split.val_fraction, config.split.seed), "random"
|
|
108
|
+
if mode == "random_stratified":
|
|
109
|
+
return (
|
|
110
|
+
stratified_split_indices(labels, config.split.val_fraction, config.split.seed),
|
|
111
|
+
"random_stratified",
|
|
112
|
+
)
|
|
113
|
+
if mode == "group":
|
|
114
|
+
groups = [sample.group for sample in samples]
|
|
115
|
+
if any(group is None for group in groups):
|
|
116
|
+
raise DataError("group split requires a group for every sample")
|
|
117
|
+
return (
|
|
118
|
+
group_split_indices(
|
|
119
|
+
[str(group) for group in groups],
|
|
120
|
+
config.split.val_fraction,
|
|
121
|
+
config.split.seed,
|
|
122
|
+
),
|
|
123
|
+
"group",
|
|
124
|
+
)
|
|
125
|
+
if mode == "train_only":
|
|
126
|
+
return SplitIndices(tuple(range(len(samples))), ()), "train_only"
|
|
127
|
+
raise ConfigError(f"unsupported split mode {mode!r}")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def resolve_normalization(
|
|
131
|
+
config: DataConfig,
|
|
132
|
+
base: Dataset[Any],
|
|
133
|
+
train_indices: tuple[int, ...],
|
|
134
|
+
) -> NormalizationStats | None:
|
|
135
|
+
"""Resolve computed, supplied, or disabled normalization statistics."""
|
|
136
|
+
if config.normalization.mode == "none":
|
|
137
|
+
return None
|
|
138
|
+
if config.normalization.mode == "supplied":
|
|
139
|
+
if config.normalization.mean is None or config.normalization.std is None:
|
|
140
|
+
raise ConfigError("supplied normalization requires mean and std")
|
|
141
|
+
return NormalizationStats(
|
|
142
|
+
mean=config.normalization.mean,
|
|
143
|
+
std=config.normalization.std,
|
|
144
|
+
source="supplied",
|
|
145
|
+
)
|
|
146
|
+
if not train_indices:
|
|
147
|
+
raise DataError("cannot compute normalization without training samples")
|
|
148
|
+
return compute_normalization(Subset(base, train_indices))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def build_bundle(
|
|
152
|
+
samples: Sequence[RawSample],
|
|
153
|
+
config: DataConfig,
|
|
154
|
+
*,
|
|
155
|
+
class_names: Sequence[str],
|
|
156
|
+
split: SplitIndices,
|
|
157
|
+
mode: str,
|
|
158
|
+
fingerprint: str,
|
|
159
|
+
group_column: str | None = None,
|
|
160
|
+
) -> DatasetBundle:
|
|
161
|
+
"""Build the final bundle, computing normalization before augmentation."""
|
|
162
|
+
names = tuple(class_names)
|
|
163
|
+
if any(sample.group is not None for sample in samples):
|
|
164
|
+
group_values = [
|
|
165
|
+
str(sample.group) if sample.group is not None else f"__ungrouped_{index}"
|
|
166
|
+
for index, sample in enumerate(samples)
|
|
167
|
+
]
|
|
168
|
+
assert_no_group_overlap(split, group_values)
|
|
169
|
+
label_to_index = {name: index for index, name in enumerate(names)}
|
|
170
|
+
base = RawDataset(samples, label_to_index, config.channels, config.value_range)
|
|
171
|
+
normalization = resolve_normalization(config, base, split.train_indices)
|
|
172
|
+
train_base = RawDataset(
|
|
173
|
+
samples,
|
|
174
|
+
label_to_index,
|
|
175
|
+
config.channels,
|
|
176
|
+
config.value_range,
|
|
177
|
+
transform=build_transforms(config, normalization, train=True),
|
|
178
|
+
)
|
|
179
|
+
eval_base = RawDataset(
|
|
180
|
+
samples,
|
|
181
|
+
label_to_index,
|
|
182
|
+
config.channels,
|
|
183
|
+
config.value_range,
|
|
184
|
+
transform=build_transforms(config, normalization, train=False),
|
|
185
|
+
)
|
|
186
|
+
test_indices = split.test_indices
|
|
187
|
+
metadata = SampleMetadata(
|
|
188
|
+
paths=tuple(
|
|
189
|
+
sample.source if isinstance(sample.source, Path) else Path(f"<sample:{index}>")
|
|
190
|
+
for index, sample in enumerate(samples)
|
|
191
|
+
),
|
|
192
|
+
labels=tuple(label_to_index[sample.label] for sample in samples),
|
|
193
|
+
groups=tuple(sample.group for sample in samples),
|
|
194
|
+
source_indices=tuple(range(len(samples))),
|
|
195
|
+
)
|
|
196
|
+
contract = ModelInputContract(
|
|
197
|
+
input_shape=(3 if config.channels == "rgb" else 1, *config.image_size),
|
|
198
|
+
dtype="float32",
|
|
199
|
+
value_range=(0.0, 1.0),
|
|
200
|
+
normalization=normalization,
|
|
201
|
+
class_names=names,
|
|
202
|
+
label_map=label_to_index,
|
|
203
|
+
channel_policy=config.channels,
|
|
204
|
+
stem_adaptation=None,
|
|
205
|
+
)
|
|
206
|
+
return DatasetBundle(
|
|
207
|
+
train=Subset(train_base, split.train_indices),
|
|
208
|
+
val=Subset(eval_base, split.val_indices),
|
|
209
|
+
test=Subset(eval_base, test_indices) if test_indices else None,
|
|
210
|
+
class_names=names,
|
|
211
|
+
in_channels=int(contract.input_shape[0]),
|
|
212
|
+
image_size=config.image_size,
|
|
213
|
+
normalization=normalization,
|
|
214
|
+
split_info=SplitInfo(
|
|
215
|
+
mode=mode, # type: ignore[arg-type]
|
|
216
|
+
train_indices=split.train_indices,
|
|
217
|
+
val_indices=split.val_indices,
|
|
218
|
+
test_indices=test_indices,
|
|
219
|
+
seed=config.split.seed,
|
|
220
|
+
group_column=group_column,
|
|
221
|
+
fingerprint=fingerprint,
|
|
222
|
+
),
|
|
223
|
+
fingerprint=fingerprint,
|
|
224
|
+
sample_metadata=metadata,
|
|
225
|
+
model_contract=contract,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def stable_sample_fingerprint(samples: Sequence[RawSample], seed: int) -> str:
|
|
230
|
+
"""Fingerprint a sample list without requiring source files to be read."""
|
|
231
|
+
payload = [
|
|
232
|
+
{
|
|
233
|
+
"source": str(sample.source) if isinstance(sample.source, Path) else "<tensor>",
|
|
234
|
+
"label": sample.label,
|
|
235
|
+
"split": sample.split,
|
|
236
|
+
"group": sample.group,
|
|
237
|
+
"severity": sample.severity,
|
|
238
|
+
}
|
|
239
|
+
for sample in samples
|
|
240
|
+
]
|
|
241
|
+
return stable_hash({"samples": payload, "seed": seed})
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""ImageFolder adapter with explicit train/val/test directories."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from cnn_classifier.config.schema import DataConfig
|
|
6
|
+
from cnn_classifier.core.errors import ConfigError, DataError
|
|
7
|
+
from cnn_classifier.core.types import DatasetBundle
|
|
8
|
+
from cnn_classifier.data.adapters._common import (
|
|
9
|
+
RawSample,
|
|
10
|
+
assign_split,
|
|
11
|
+
build_bundle,
|
|
12
|
+
resolve_class_names,
|
|
13
|
+
stable_sample_fingerprint,
|
|
14
|
+
)
|
|
15
|
+
from cnn_classifier.data.adapters.registry import register_dataset
|
|
16
|
+
|
|
17
|
+
_IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".tif", ".tiff", ".bmp", ".webp"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@register_dataset("image_folder")
|
|
21
|
+
class ImageFolderAdapter:
|
|
22
|
+
"""Read class directories from train/val/test split folders."""
|
|
23
|
+
|
|
24
|
+
@staticmethod
|
|
25
|
+
def build(config: DataConfig) -> DatasetBundle:
|
|
26
|
+
if config.root is None:
|
|
27
|
+
raise ConfigError("image_folder adapter requires data.root")
|
|
28
|
+
split_dirs = config.adapter_params.get(
|
|
29
|
+
"split_dirs", {"train": "train", "val": "val", "test": "test"}
|
|
30
|
+
)
|
|
31
|
+
samples: list[RawSample] = []
|
|
32
|
+
observed_classes: set[str] = set()
|
|
33
|
+
for split_name in ("train", "val", "test"):
|
|
34
|
+
directory = config.root / split_dirs[split_name]
|
|
35
|
+
if not directory.is_dir():
|
|
36
|
+
if split_name == "test":
|
|
37
|
+
continue
|
|
38
|
+
raise DataError(f"image_folder split directory does not exist: {directory}")
|
|
39
|
+
for class_dir in sorted(path for path in directory.iterdir() if path.is_dir()):
|
|
40
|
+
observed_classes.add(class_dir.name)
|
|
41
|
+
for image_path in sorted(class_dir.iterdir()):
|
|
42
|
+
if image_path.is_file() and image_path.suffix.lower() in _IMAGE_SUFFIXES:
|
|
43
|
+
samples.append(
|
|
44
|
+
RawSample(
|
|
45
|
+
source=image_path,
|
|
46
|
+
label=class_dir.name,
|
|
47
|
+
split=split_name,
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
if not samples:
|
|
51
|
+
raise DataError(f"image_folder found no images under {config.root}")
|
|
52
|
+
class_names = resolve_class_names(sorted(observed_classes), config)
|
|
53
|
+
explicit = config.model_copy(
|
|
54
|
+
update={"split": config.split.model_copy(update={"mode": "explicit"})}
|
|
55
|
+
)
|
|
56
|
+
split, mode = assign_split(samples, explicit)
|
|
57
|
+
fingerprint = stable_sample_fingerprint(samples, config.split.seed)
|
|
58
|
+
return build_bundle(
|
|
59
|
+
samples,
|
|
60
|
+
explicit,
|
|
61
|
+
class_names=class_names,
|
|
62
|
+
split=split,
|
|
63
|
+
mode=mode,
|
|
64
|
+
fingerprint=fingerprint,
|
|
65
|
+
)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""CSV manifest adapter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from cnn_classifier.config.schema import DataConfig
|
|
6
|
+
from cnn_classifier.core.errors import ConfigError
|
|
7
|
+
from cnn_classifier.core.types import DatasetBundle
|
|
8
|
+
from cnn_classifier.data.adapters._common import (
|
|
9
|
+
RawSample,
|
|
10
|
+
assign_split,
|
|
11
|
+
build_bundle,
|
|
12
|
+
resolve_class_names,
|
|
13
|
+
)
|
|
14
|
+
from cnn_classifier.data.adapters.registry import register_dataset
|
|
15
|
+
from cnn_classifier.data.manifest import Manifest, fingerprint_records
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@register_dataset("manifest")
|
|
19
|
+
class ManifestAdapter:
|
|
20
|
+
"""Read images and labels from a CSV manifest."""
|
|
21
|
+
|
|
22
|
+
@staticmethod
|
|
23
|
+
def build(config: DataConfig) -> DatasetBundle:
|
|
24
|
+
if config.manifest is None:
|
|
25
|
+
raise ConfigError("manifest adapter requires data.manifest")
|
|
26
|
+
image_root = config.image_root or config.manifest.parent
|
|
27
|
+
columns = config.adapter_params.get(
|
|
28
|
+
"columns",
|
|
29
|
+
{
|
|
30
|
+
"path": "path",
|
|
31
|
+
"label": "label",
|
|
32
|
+
"split": "split",
|
|
33
|
+
"group": "group",
|
|
34
|
+
"severity": "severity",
|
|
35
|
+
},
|
|
36
|
+
)
|
|
37
|
+
manifest = Manifest.from_csv(config.manifest, image_root, columns)
|
|
38
|
+
samples = [
|
|
39
|
+
RawSample(
|
|
40
|
+
source=record.path,
|
|
41
|
+
label=record.label,
|
|
42
|
+
split=record.split,
|
|
43
|
+
group=record.group,
|
|
44
|
+
severity=record.severity,
|
|
45
|
+
)
|
|
46
|
+
for record in manifest.records
|
|
47
|
+
]
|
|
48
|
+
class_names = resolve_class_names([sample.label for sample in samples], config)
|
|
49
|
+
explicit = config.model_copy(
|
|
50
|
+
update={"split": config.split.model_copy(update={"mode": "explicit"})}
|
|
51
|
+
)
|
|
52
|
+
split, mode = assign_split(samples, explicit)
|
|
53
|
+
fingerprint = fingerprint_records(manifest.records)
|
|
54
|
+
return build_bundle(
|
|
55
|
+
samples,
|
|
56
|
+
explicit,
|
|
57
|
+
class_names=class_names,
|
|
58
|
+
split=split,
|
|
59
|
+
mode=mode,
|
|
60
|
+
fingerprint=fingerprint,
|
|
61
|
+
group_column=config.split.group_column,
|
|
62
|
+
)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""NumPy array adapter for scientific image tensors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import torch
|
|
9
|
+
|
|
10
|
+
from cnn_classifier.config.schema import DataConfig
|
|
11
|
+
from cnn_classifier.core.errors import ConfigError, DataError
|
|
12
|
+
from cnn_classifier.core.hashing import stable_hash
|
|
13
|
+
from cnn_classifier.core.types import DatasetBundle
|
|
14
|
+
from cnn_classifier.data.adapters._common import (
|
|
15
|
+
RawSample,
|
|
16
|
+
assign_split,
|
|
17
|
+
build_bundle,
|
|
18
|
+
resolve_class_names,
|
|
19
|
+
)
|
|
20
|
+
from cnn_classifier.data.adapters.registry import register_dataset
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _load_arrays(array_path: Path, labels_path: Path | None) -> tuple[np.ndarray, np.ndarray]:
|
|
24
|
+
try:
|
|
25
|
+
if array_path.suffix == ".npz":
|
|
26
|
+
payload = np.load(array_path)
|
|
27
|
+
images = np.asarray(payload["images"])
|
|
28
|
+
labels = np.asarray(payload["labels"] if labels_path is None else np.load(labels_path))
|
|
29
|
+
else:
|
|
30
|
+
images = np.asarray(np.load(array_path))
|
|
31
|
+
if labels_path is None:
|
|
32
|
+
raise ConfigError("numpy adapter requires labels_path unless the array is .npz")
|
|
33
|
+
labels = np.asarray(np.load(labels_path))
|
|
34
|
+
except OSError as exc:
|
|
35
|
+
raise DataError(f"cannot load NumPy dataset: {exc}") from exc
|
|
36
|
+
if images.ndim == 3:
|
|
37
|
+
images = images[:, None, :, :]
|
|
38
|
+
if images.ndim != 4:
|
|
39
|
+
raise DataError(f"numpy images must be [N,C,H,W] or [N,H,W]; got {images.shape}")
|
|
40
|
+
if len(images) != len(labels):
|
|
41
|
+
raise DataError("numpy images and labels have different lengths")
|
|
42
|
+
return images, labels
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _to_float(images: np.ndarray) -> np.ndarray:
|
|
46
|
+
data = images.astype(np.float64)
|
|
47
|
+
if np.issubdtype(images.dtype, np.integer):
|
|
48
|
+
data = data / float(np.iinfo(images.dtype).max)
|
|
49
|
+
return np.clip(data, 0.0, 1.0).astype(np.float32)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@register_dataset("numpy")
|
|
53
|
+
class NumpyAdapter:
|
|
54
|
+
"""Read images and labels from .npy or .npz arrays."""
|
|
55
|
+
|
|
56
|
+
@staticmethod
|
|
57
|
+
def build(config: DataConfig) -> DatasetBundle:
|
|
58
|
+
raw_path = config.adapter_params.get("array_path")
|
|
59
|
+
if raw_path is None:
|
|
60
|
+
raise ConfigError("numpy adapter requires adapter_params.array_path")
|
|
61
|
+
array_path = Path(raw_path)
|
|
62
|
+
if not array_path.is_absolute() and config.root is not None:
|
|
63
|
+
array_path = config.root / array_path
|
|
64
|
+
labels_raw = config.adapter_params.get("labels_path")
|
|
65
|
+
labels_path = Path(labels_raw) if labels_raw is not None else None
|
|
66
|
+
if labels_path is not None and not labels_path.is_absolute() and config.root is not None:
|
|
67
|
+
labels_path = config.root / labels_path
|
|
68
|
+
images, labels = _load_arrays(array_path, labels_path)
|
|
69
|
+
images = _to_float(images)
|
|
70
|
+
class_values = sorted({int(value) for value in labels.tolist()})
|
|
71
|
+
class_names = resolve_class_names([f"class_{value}" for value in class_values], config)
|
|
72
|
+
samples = [
|
|
73
|
+
RawSample(
|
|
74
|
+
source=torch.from_numpy(images[index]),
|
|
75
|
+
label=f"class_{int(labels[index])}",
|
|
76
|
+
split="train",
|
|
77
|
+
)
|
|
78
|
+
for index in range(len(images))
|
|
79
|
+
]
|
|
80
|
+
split, mode = assign_split(samples, config)
|
|
81
|
+
fingerprint = stable_hash(
|
|
82
|
+
{
|
|
83
|
+
"adapter": "numpy",
|
|
84
|
+
"shape": list(images.shape),
|
|
85
|
+
"labels": labels.tolist(),
|
|
86
|
+
"seed": config.split.seed,
|
|
87
|
+
}
|
|
88
|
+
)
|
|
89
|
+
return build_bundle(
|
|
90
|
+
samples,
|
|
91
|
+
config,
|
|
92
|
+
class_names=class_names,
|
|
93
|
+
split=split,
|
|
94
|
+
mode=mode,
|
|
95
|
+
fingerprint=fingerprint,
|
|
96
|
+
)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Dataset adapter registry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import TypeAlias
|
|
7
|
+
|
|
8
|
+
from cnn_classifier.config.schema import DataConfig
|
|
9
|
+
from cnn_classifier.core.errors import ConfigError, RegistryError
|
|
10
|
+
from cnn_classifier.core.registry import Registry
|
|
11
|
+
from cnn_classifier.core.types import DatasetBundle
|
|
12
|
+
from cnn_classifier.data.base import DatasetAdapter
|
|
13
|
+
|
|
14
|
+
AdapterClass: TypeAlias = type[DatasetAdapter]
|
|
15
|
+
DATASETS: Registry[AdapterClass] = Registry("dataset")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def register_dataset(name: str) -> Callable[[AdapterClass], AdapterClass]:
|
|
19
|
+
"""Register a dataset adapter class under ``name``."""
|
|
20
|
+
return DATASETS.register(name)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def build_dataset(config: DataConfig) -> DatasetBundle:
|
|
24
|
+
"""Resolve and build the configured dataset adapter."""
|
|
25
|
+
try:
|
|
26
|
+
adapter = DATASETS.get(config.adapter)
|
|
27
|
+
except RegistryError as exc:
|
|
28
|
+
raise ConfigError(f"unknown dataset adapter {config.adapter!r}") from exc
|
|
29
|
+
return adapter.build(config)
|