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.
Files changed (89) hide show
  1. cnn_classifier/__init__.py +5 -0
  2. cnn_classifier/_version.py +1 -0
  3. cnn_classifier/cli.py +1085 -0
  4. cnn_classifier/config/__init__.py +17 -0
  5. cnn_classifier/config/loader.py +102 -0
  6. cnn_classifier/config/presets/default.yaml +47 -0
  7. cnn_classifier/config/resolve.py +108 -0
  8. cnn_classifier/config/schema.py +251 -0
  9. cnn_classifier/core/__init__.py +19 -0
  10. cnn_classifier/core/device.py +50 -0
  11. cnn_classifier/core/errors.py +21 -0
  12. cnn_classifier/core/hashing.py +18 -0
  13. cnn_classifier/core/registry.py +46 -0
  14. cnn_classifier/core/seeding.py +37 -0
  15. cnn_classifier/core/types.py +97 -0
  16. cnn_classifier/data/__init__.py +22 -0
  17. cnn_classifier/data/adapters/__init__.py +19 -0
  18. cnn_classifier/data/adapters/_common.py +241 -0
  19. cnn_classifier/data/adapters/image_folder.py +65 -0
  20. cnn_classifier/data/adapters/manifest.py +62 -0
  21. cnn_classifier/data/adapters/numpy.py +96 -0
  22. cnn_classifier/data/adapters/registry.py +29 -0
  23. cnn_classifier/data/adapters/synthetic.py +114 -0
  24. cnn_classifier/data/adapters/torchvision.py +192 -0
  25. cnn_classifier/data/base.py +17 -0
  26. cnn_classifier/data/contract.py +28 -0
  27. cnn_classifier/data/image_io.py +116 -0
  28. cnn_classifier/data/inspection.py +111 -0
  29. cnn_classifier/data/loaders.py +79 -0
  30. cnn_classifier/data/manifest.py +98 -0
  31. cnn_classifier/data/splits.py +136 -0
  32. cnn_classifier/data/stats.py +106 -0
  33. cnn_classifier/data/transforms.py +192 -0
  34. cnn_classifier/diagnostics/__init__.py +22 -0
  35. cnn_classifier/diagnostics/activations.py +56 -0
  36. cnn_classifier/diagnostics/architecture.py +172 -0
  37. cnn_classifier/diagnostics/determinism.py +25 -0
  38. cnn_classifier/diagnostics/gradients.py +32 -0
  39. cnn_classifier/diagnostics/sanity.py +49 -0
  40. cnn_classifier/diagnostics/tracing.py +56 -0
  41. cnn_classifier/evaluation/__init__.py +53 -0
  42. cnn_classifier/evaluation/bootstrap.py +96 -0
  43. cnn_classifier/evaluation/calibration.py +74 -0
  44. cnn_classifier/evaluation/compare.py +111 -0
  45. cnn_classifier/evaluation/error_analysis.py +61 -0
  46. cnn_classifier/evaluation/evaluator.py +143 -0
  47. cnn_classifier/evaluation/latency.py +58 -0
  48. cnn_classifier/evaluation/metrics.py +94 -0
  49. cnn_classifier/evaluation/predict.py +64 -0
  50. cnn_classifier/models/__init__.py +50 -0
  51. cnn_classifier/models/base.py +62 -0
  52. cnn_classifier/models/residual_cnn.py +138 -0
  53. cnn_classifier/models/simple_cnn.py +55 -0
  54. cnn_classifier/models/summary.py +39 -0
  55. cnn_classifier/models/torchvision.py +46 -0
  56. cnn_classifier/models/transfer.py +193 -0
  57. cnn_classifier/runs/__init__.py +6 -0
  58. cnn_classifier/runs/artifacts.py +50 -0
  59. cnn_classifier/runs/context.py +130 -0
  60. cnn_classifier/tracking/__init__.py +34 -0
  61. cnn_classifier/tracking/base.py +16 -0
  62. cnn_classifier/tracking/composite.py +20 -0
  63. cnn_classifier/tracking/console.py +14 -0
  64. cnn_classifier/tracking/csv.py +19 -0
  65. cnn_classifier/tracking/tensorboard.py +22 -0
  66. cnn_classifier/training/__init__.py +37 -0
  67. cnn_classifier/training/amp.py +51 -0
  68. cnn_classifier/training/callbacks.py +229 -0
  69. cnn_classifier/training/checkpoint.py +151 -0
  70. cnn_classifier/training/losses.py +67 -0
  71. cnn_classifier/training/optim.py +40 -0
  72. cnn_classifier/training/schedulers.py +53 -0
  73. cnn_classifier/training/state.py +35 -0
  74. cnn_classifier/training/trainer.py +357 -0
  75. cnn_classifier/viz/__init__.py +42 -0
  76. cnn_classifier/viz/architecture.py +276 -0
  77. cnn_classifier/viz/compare.py +36 -0
  78. cnn_classifier/viz/data.py +56 -0
  79. cnn_classifier/viz/evaluation.py +134 -0
  80. cnn_classifier/viz/features.py +122 -0
  81. cnn_classifier/viz/report.py +821 -0
  82. cnn_classifier/viz/style.py +69 -0
  83. cnn_classifier/viz/templates/report.html.j2 +355 -0
  84. cnn_classifier/viz/training.py +83 -0
  85. cnn_classifier_toolkit-0.1.0.dist-info/METADATA +249 -0
  86. cnn_classifier_toolkit-0.1.0.dist-info/RECORD +89 -0
  87. cnn_classifier_toolkit-0.1.0.dist-info/WHEEL +4 -0
  88. cnn_classifier_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
  89. 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)