budijuarto commited on
Commit
5aba42e
·
verified ·
1 Parent(s): 0258b57

Upload src/egg_damage/data_discovery.py

Browse files
Files changed (1) hide show
  1. src/egg_damage/data_discovery.py +297 -0
src/egg_damage/data_discovery.py ADDED
@@ -0,0 +1,297 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import os
5
+ import shutil
6
+ import subprocess
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import pandas as pd
12
+ from sklearn.model_selection import train_test_split
13
+
14
+ from .config import load_config, save_config_snapshot
15
+ from .paths import ensure_dir
16
+ from .utils import get_logger
17
+
18
+
19
+ LOGGER = get_logger(__name__)
20
+
21
+ CANONICAL_LABELS = ("Not Damaged", "Damaged")
22
+ LABEL_TO_ID = {"Not Damaged": 0, "Damaged": 1}
23
+ ID_TO_LABEL = {0: "Not Damaged", 1: "Damaged"}
24
+ SPLIT_ALIASES = {
25
+ "train": {"train", "training", "trn"},
26
+ "val": {"val", "valid", "validation", "dev"},
27
+ "test": {"test", "testing", "tst"},
28
+ }
29
+ IGNORED_PARTS = {"__macosx", ".ipynb_checkpoints"}
30
+
31
+
32
+ def normalize_name(value: str) -> str:
33
+ return "".join(ch for ch in value.lower() if ch.isalnum())
34
+
35
+
36
+ def detect_label_from_name(name: str) -> str | None:
37
+ normalized = normalize_name(name)
38
+ not_damaged_markers = {
39
+ "notdamaged",
40
+ "undamaged",
41
+ "nodamage",
42
+ "nondamaged",
43
+ "intact",
44
+ "normal",
45
+ "healthy",
46
+ "good",
47
+ "clean",
48
+ "fresh",
49
+ }
50
+ damaged_markers = {"damaged", "damage", "cracked", "crack", "broken", "defect", "defective"}
51
+ if any(marker in normalized for marker in not_damaged_markers):
52
+ return "Not Damaged"
53
+ if any(marker in normalized for marker in damaged_markers):
54
+ return "Damaged"
55
+ return None
56
+
57
+
58
+ def detect_split_from_name(name: str) -> str | None:
59
+ normalized = normalize_name(name)
60
+ for split, aliases in SPLIT_ALIASES.items():
61
+ if normalized in {normalize_name(alias) for alias in aliases}:
62
+ return split
63
+ return None
64
+
65
+
66
+ def is_hidden_or_system(path: Path) -> bool:
67
+ return any(part.startswith(".") or part.lower() in IGNORED_PARTS for part in path.parts)
68
+
69
+
70
+ def iter_image_files(root: Path, extensions: set[str]) -> list[Path]:
71
+ images: list[Path] = []
72
+ for path in root.rglob("*"):
73
+ if path.is_file() and path.suffix.lower() in extensions and not is_hidden_or_system(path):
74
+ images.append(path.resolve())
75
+ return sorted(images)
76
+
77
+
78
+ def label_for_path(path: Path, root: Path) -> str | None:
79
+ try:
80
+ parts = path.relative_to(root).parts[:-1]
81
+ except ValueError:
82
+ parts = path.parts[:-1]
83
+ for part in reversed(parts):
84
+ label = detect_label_from_name(part)
85
+ if label:
86
+ return label
87
+ return detect_label_from_name(path.stem)
88
+
89
+
90
+ def split_for_path(path: Path, root: Path) -> str | None:
91
+ try:
92
+ parts = path.relative_to(root).parts[:-1]
93
+ except ValueError:
94
+ parts = path.parts[:-1]
95
+ for part in parts:
96
+ split = detect_split_from_name(part)
97
+ if split:
98
+ return split
99
+ return None
100
+
101
+
102
+ def build_labeled_dataframe(root: str | Path, config: dict[str, Any]) -> pd.DataFrame:
103
+ root = Path(root).expanduser().resolve()
104
+ if not root.exists():
105
+ raise FileNotFoundError(f"Dataset path does not exist: {root}")
106
+ extensions = {ext.lower() for ext in config["data"]["image_extensions"]}
107
+ rows: list[dict[str, str]] = []
108
+ for path in iter_image_files(root, extensions):
109
+ label = label_for_path(path, root)
110
+ if label is None:
111
+ continue
112
+ rows.append(
113
+ {
114
+ "filepath": str(path),
115
+ "label": label,
116
+ "split": split_for_path(path, root) or "",
117
+ }
118
+ )
119
+ if not rows:
120
+ raise ValueError(
121
+ "No labeled images were detected. Expected folders or filenames resembling "
122
+ "'Damaged', 'Not Damaged', 'cracked', 'undamaged', 'normal', or similar."
123
+ )
124
+ df = pd.DataFrame(rows).drop_duplicates(subset=["filepath"]).reset_index(drop=True)
125
+ labels = set(df["label"])
126
+ missing = set(CANONICAL_LABELS) - labels
127
+ if missing:
128
+ raise ValueError(f"Detected labels {sorted(labels)}, but missing classes: {sorted(missing)}")
129
+ return df
130
+
131
+
132
+ def create_stratified_splits(df: pd.DataFrame, config: dict[str, Any]) -> pd.DataFrame:
133
+ seed = int(config["seed"])
134
+ train_size = float(config["data"]["train_size"])
135
+ val_size = float(config["data"]["val_size"])
136
+ test_size = float(config["data"]["test_size"])
137
+ total = train_size + val_size + test_size
138
+ if abs(total - 1.0) > 1e-6:
139
+ train_size, val_size, test_size = train_size / total, val_size / total, test_size / total
140
+
141
+ if df["label"].value_counts().min() < 3:
142
+ raise ValueError("Each class needs at least 3 images for a 70/15/15 stratified split.")
143
+
144
+ train_df, temp_df = train_test_split(
145
+ df.drop(columns=["split"], errors="ignore"),
146
+ train_size=train_size,
147
+ stratify=df["label"],
148
+ random_state=seed,
149
+ )
150
+ relative_test = test_size / (val_size + test_size)
151
+ val_df, test_df = train_test_split(
152
+ temp_df,
153
+ test_size=relative_test,
154
+ stratify=temp_df["label"],
155
+ random_state=seed,
156
+ )
157
+ train_df = train_df.assign(split="train")
158
+ val_df = val_df.assign(split="val")
159
+ test_df = test_df.assign(split="test")
160
+ return pd.concat([train_df, val_df, test_df], ignore_index=True).sort_values(
161
+ ["split", "label", "filepath"]
162
+ )
163
+
164
+
165
+ def complete_or_create_splits(df: pd.DataFrame, config: dict[str, Any]) -> pd.DataFrame:
166
+ known = df["split"].replace("", pd.NA).dropna()
167
+ if known.empty:
168
+ LOGGER.info("No existing train/val/test split folders detected; creating stratified splits.")
169
+ return create_stratified_splits(df, config)
170
+
171
+ df = df[df["split"].isin(["train", "val", "test"])].copy()
172
+ if df.empty:
173
+ return create_stratified_splits(df, config)
174
+ present = set(df["split"].unique())
175
+ if {"train", "val", "test"}.issubset(present):
176
+ LOGGER.info("Existing train/val/test split folders detected.")
177
+ return df.sort_values(["split", "label", "filepath"]).reset_index(drop=True)
178
+ if "train" in present and "val" not in present:
179
+ LOGGER.info("Existing split lacks validation data; carving validation from train only.")
180
+ train_mask = df["split"] == "train"
181
+ train_part = df[train_mask].drop(columns=["split"])
182
+ if train_part["label"].value_counts().min() >= 2:
183
+ new_train, new_val = train_test_split(
184
+ train_part,
185
+ test_size=float(config["data"]["val_size"]),
186
+ stratify=train_part["label"],
187
+ random_state=int(config["seed"]),
188
+ )
189
+ rest = df[~train_mask]
190
+ df = pd.concat(
191
+ [new_train.assign(split="train"), new_val.assign(split="val"), rest],
192
+ ignore_index=True,
193
+ )
194
+ missing = {"train", "val", "test"} - set(df["split"].unique())
195
+ if missing:
196
+ LOGGER.warning("Missing split(s) %s; evaluation will use the available splits.", sorted(missing))
197
+ return df.sort_values(["split", "label", "filepath"]).reset_index(drop=True)
198
+
199
+
200
+ def add_label_ids(df: pd.DataFrame) -> pd.DataFrame:
201
+ out = df.copy()
202
+ out["label_id"] = out["label"].map(LABEL_TO_ID).astype(int)
203
+ return out
204
+
205
+
206
+ def discover_dataset(config: dict[str, Any], data_dir: str | Path | None = None) -> pd.DataFrame:
207
+ root = Path(data_dir or config["paths"]["data_dir"]).expanduser().resolve()
208
+ df = build_labeled_dataframe(root, config)
209
+ df = complete_or_create_splits(df, config)
210
+ df = add_label_ids(df)
211
+ return df[["filepath", "label", "label_id", "split"]].reset_index(drop=True)
212
+
213
+
214
+ def class_distribution(df: pd.DataFrame) -> pd.DataFrame:
215
+ return (
216
+ df.groupby(["split", "label"], observed=False)
217
+ .size()
218
+ .reset_index(name="count")
219
+ .sort_values(["split", "label"])
220
+ )
221
+
222
+
223
+ def print_class_distribution(df: pd.DataFrame) -> None:
224
+ dist = class_distribution(df)
225
+ LOGGER.info("Class distribution:\n%s", dist.to_string(index=False))
226
+ for split, split_df in df.groupby("split"):
227
+ counts = split_df["label"].value_counts()
228
+ if len(counts) == 2:
229
+ ratio = counts.max() / max(counts.min(), 1)
230
+ LOGGER.info("%s imbalance ratio: %.2f", split, ratio)
231
+
232
+
233
+ def save_split_metadata(df: pd.DataFrame, config: dict[str, Any]) -> Path:
234
+ output_dir = ensure_dir(config["paths"]["output_dir"])
235
+ split_csv = Path(config["paths"]["split_csv"])
236
+ split_csv.parent.mkdir(parents=True, exist_ok=True)
237
+ df.to_csv(split_csv, index=False)
238
+ class_distribution(df).to_csv(output_dir / "class_distribution.csv", index=False)
239
+ save_config_snapshot(config, output_dir)
240
+ LOGGER.info("Saved split metadata: %s", split_csv)
241
+ return split_csv
242
+
243
+
244
+ def kaggle_credentials_available() -> bool:
245
+ if Path.home().joinpath(".kaggle", "kaggle.json").exists():
246
+ return True
247
+ return bool({"KAGGLE_USERNAME", "KAGGLE_KEY"}.issubset(set(os.environ)))
248
+
249
+
250
+ def download_kaggle_dataset(config: dict[str, Any]) -> Path:
251
+ dataset = config["kaggle"]["dataset"]
252
+ download_dir = ensure_dir(config["kaggle"]["download_dir"])
253
+ if not kaggle_credentials_available():
254
+ raise RuntimeError(
255
+ "Kaggle credentials were not found. Configure ~/.kaggle/kaggle.json or "
256
+ "KAGGLE_USERNAME/KAGGLE_KEY, then retry."
257
+ )
258
+ command = shutil.which("kaggle")
259
+ if command:
260
+ cmd = [command, "datasets", "download", "-d", dataset, "-p", str(download_dir), "--unzip"]
261
+ else:
262
+ cmd = [sys.executable, "-m", "kaggle", "datasets", "download", "-d", dataset, "-p", str(download_dir), "--unzip"]
263
+ LOGGER.info("Downloading Kaggle dataset %s to %s", dataset, download_dir)
264
+ subprocess.run(cmd, check=True)
265
+ return download_dir
266
+
267
+
268
+ def prepare_data(config: dict[str, Any], data_dir: str | Path | None = None, download: bool = False) -> pd.DataFrame:
269
+ if download or config.get("kaggle", {}).get("enabled", False):
270
+ data_dir = download_kaggle_dataset(config)
271
+ config["paths"]["data_dir"] = str(data_dir)
272
+ df = discover_dataset(config, data_dir)
273
+ print_class_distribution(df)
274
+ save_split_metadata(df, config)
275
+ try:
276
+ from .reporting import plot_class_distribution
277
+
278
+ plot_class_distribution(df, Path(config["paths"]["output_dir"]) / "plots" / "class_distribution.png")
279
+ except Exception as exc:
280
+ LOGGER.warning("Could not save class distribution plot: %s", exc)
281
+ return df
282
+
283
+
284
+ def main() -> None:
285
+ parser = argparse.ArgumentParser(description="Discover and split egg damage image dataset.")
286
+ parser.add_argument("--config", default="configs/default.yaml")
287
+ parser.add_argument("--data-dir", default=None)
288
+ parser.add_argument("--download-kaggle", action="store_true")
289
+ args = parser.parse_args()
290
+ config = load_config(args.config)
291
+ if args.data_dir:
292
+ config["paths"]["data_dir"] = str(Path(args.data_dir).expanduser().resolve())
293
+ prepare_data(config, args.data_dir, args.download_kaggle)
294
+
295
+
296
+ if __name__ == "__main__":
297
+ main()