ochsncon commited on
Commit
b99d10a
·
verified ·
1 Parent(s): b224897

Update src/train_vision_model.py

Browse files
Files changed (1) hide show
  1. src/train_vision_model.py +261 -141
src/train_vision_model.py CHANGED
@@ -1,172 +1,292 @@
1
- """Train and persist the local vehicle-class vision model.
2
 
3
- This uses a lightweight local classifier on handcrafted image features from the
4
- expanded dataset stored under data/raw/Cars Dataset/train and data/raw/Cars Dataset/test.
 
 
 
 
 
 
5
  """
6
 
7
  from __future__ import annotations
8
 
 
9
  import json
 
10
  import sys
11
  from datetime import datetime, timezone
12
  from pathlib import Path
13
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  PROJECT_ROOT = Path(__file__).resolve().parent.parent
15
  if str(PROJECT_ROOT) not in sys.path:
16
  sys.path.insert(0, str(PROJECT_ROOT))
17
 
18
- import joblib
19
- import numpy as np
20
- from PIL import Image
21
- from sklearn.ensemble import ExtraTreesClassifier
22
- from sklearn.metrics import accuracy_score, classification_report, confusion_matrix
23
-
24
- from src.config import DATA_RAW_DIR, MODEL_DIR, VISION_LABELS_PATH, VISION_METADATA_PATH, VISION_MODEL_PATH
25
- from src.vision_features import extract_image_features
26
-
27
-
28
- def _find_image_root() -> Path:
29
- candidates = [
30
- DATA_RAW_DIR / "Cars Dataset",
31
- DATA_RAW_DIR / "car_images",
32
- ]
33
- for candidate in candidates:
34
- if candidate.exists():
35
- return candidate
36
- raise FileNotFoundError("No image dataset found in data/raw/Cars Dataset or data/raw/car_images.")
37
-
38
-
39
- def _collect_image_paths(image_root: Path) -> tuple[list[Path], list[Path], list[str]]:
40
- train_dir = image_root / "train"
41
- test_dir = image_root / "test"
42
- if not train_dir.exists() or not test_dir.exists():
43
- raise FileNotFoundError("Expected train/ and test/ folders inside the image dataset root.")
44
-
45
- classes = sorted([path.name for path in train_dir.iterdir() if path.is_dir()])
46
- train_paths: list[Path] = []
47
- test_paths: list[Path] = []
48
-
49
- for class_name in classes:
50
- for split_paths, split_dir in ((train_paths, train_dir), (test_paths, test_dir)):
51
- class_dir = split_dir / class_name
52
- for pattern in ("*.jpg", "*.jpeg", "*.png", "*.webp"):
53
- split_paths.extend(sorted(class_dir.glob(pattern)))
54
-
55
- return train_paths, test_paths, classes
56
-
57
-
58
- def _load_grouped_features(paths: list[Path], classes: list[str]) -> dict[str, list[np.ndarray]]:
59
- grouped_features: dict[str, list[np.ndarray]] = {class_name: [] for class_name in classes}
60
-
61
- for image_path in paths:
62
- class_name = image_path.parent.name
63
- try:
64
- image = Image.open(image_path)
65
- grouped_features[class_name].append(extract_image_features(image))
66
- except Exception:
67
- continue
68
-
69
- return grouped_features
70
-
71
-
72
- def _rebalance_training_data(
73
- grouped_features: dict[str, list[np.ndarray]],
74
- classes: list[str],
75
- target_per_class: int = 200,
76
- ) -> tuple[np.ndarray, np.ndarray]:
77
- rng = np.random.default_rng(42)
78
- features = []
79
- labels = []
80
-
81
- for class_index, class_name in enumerate(classes):
82
- class_features = grouped_features.get(class_name, [])
83
- if not class_features:
84
- continue
85
-
86
- class_array = np.asarray(class_features, dtype=np.float32)
87
- sample_size = min(target_per_class, max(len(class_array), 1))
88
- replace = len(class_array) < sample_size
89
- selected_indices = rng.choice(len(class_array), size=sample_size, replace=replace)
90
- selected_features = class_array[selected_indices]
91
-
92
- features.append(selected_features)
93
- labels.extend([class_index] * sample_size)
94
-
95
- if not features:
96
- return np.empty((0, 0), dtype=np.float32), np.empty((0,), dtype=np.int64)
97
-
98
- return np.vstack(features), np.asarray(labels, dtype=np.int64)
99
-
100
-
101
- def main() -> None:
102
- MODEL_DIR.mkdir(parents=True, exist_ok=True)
103
- image_root = _find_image_root()
104
- train_paths, test_paths, classes = _collect_image_paths(image_root)
105
- train_grouped = _load_grouped_features(train_paths, classes)
106
- test_grouped = _load_grouped_features(test_paths, classes)
107
-
108
- X_train, y_train = _rebalance_training_data(train_grouped, classes, target_per_class=200)
109
- X_test, y_test = _rebalance_training_data(test_grouped, classes, target_per_class=200)
110
-
111
- if len(X_train) == 0 or len(X_test) == 0:
112
- raise RuntimeError("Could not extract any usable image features from the dataset.")
113
-
114
- epochs = int(sys.argv[1]) if len(sys.argv) > 1 else 1
115
- model = ExtraTreesClassifier(
116
- n_estimators=max(800, 200 * epochs),
117
- max_depth=30, # Prevent overfitting on noise
118
- min_samples_split=5, # Require 5+ samples to split
119
- min_samples_leaf=2, # Require 2+ samples at leaf
120
- class_weight="balanced", # Handle class imbalance (especially Audi)
121
- random_state=42,
122
- n_jobs=-1,
123
  )
124
- model.fit(X_train, y_train)
125
 
126
- predictions = model.predict(X_test)
127
- accuracy = float(accuracy_score(y_test, predictions))
 
 
 
 
 
 
 
 
128
 
129
- # Only include labels that actually appear in the test set to avoid
130
- # mismatches between `classes` and present test labels (can happen when
131
- # a class has no extracted test samples after filtering).
132
- present_label_indices = sorted(np.unique(y_test).tolist())
133
- present_class_names = [classes[idx] for idx in present_label_indices]
 
 
 
134
 
135
- report = classification_report(
136
- y_test,
137
- predictions,
138
- labels=present_label_indices,
139
- target_names=present_class_names,
140
- output_dict=True,
141
- zero_division=0,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
  )
143
 
144
- matrix = confusion_matrix(y_test, predictions, labels=present_label_indices).tolist()
 
 
 
 
 
 
 
 
145
 
146
- print(f"Test accuracy: {accuracy:.4f}")
147
- print("Per-class recall (test-set classes):")
148
- for class_name in present_class_names:
149
- print(f"- {class_name}: {report[class_name]['recall']:.4f}")
150
 
151
- joblib.dump(model, VISION_MODEL_PATH, compress=3)
152
- VISION_LABELS_PATH.write_text(json.dumps(classes, indent=2), encoding="utf-8")
 
 
153
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
  metadata = {
155
  "created_at": datetime.now(timezone.utc).isoformat(),
156
- "data_source": str(image_root),
157
- "model_name": "ExtraTreesClassifier",
158
- "train_epochs": epochs,
159
- "classes": classes,
160
- "test_accuracy": round(accuracy, 4),
161
- "training_strategy": "balanced_sampling_target_200",
162
  "classification_report": report,
163
- "confusion_matrix": matrix,
164
  }
165
- VISION_METADATA_PATH.write_text(json.dumps(metadata, indent=2), encoding="utf-8")
166
 
167
- print(f"Saved model to: {VISION_MODEL_PATH}")
168
- print(f"Saved labels to: {VISION_LABELS_PATH}")
169
- print(f"Saved metadata to: {VISION_METADATA_PATH}")
 
 
 
 
 
 
 
 
 
170
 
171
 
172
  if __name__ == "__main__":
 
1
+ """Train a transfer-learning vehicle brand classifier using ResNet-18.
2
 
3
+ This script uses:
4
+ - datasets.load_dataset("imagefolder") for data loading
5
+ - AutoImageProcessor for image preprocessing
6
+ - AutoModelForImageClassification for transfer learning
7
+ - transformers.Trainer for training
8
+ - Proper data augmentation and evaluation
9
+
10
+ The trained model is saved in Hugging Face format under models/car-image-classifier/
11
  """
12
 
13
  from __future__ import annotations
14
 
15
+ import argparse
16
  import json
17
+ import random
18
  import sys
19
  from datetime import datetime, timezone
20
  from pathlib import Path
21
 
22
+ import evaluate
23
+ import numpy as np
24
+ from datasets import load_dataset
25
+ from sklearn.metrics import classification_report
26
+ from transformers import (
27
+ AutoImageProcessor,
28
+ AutoModelForImageClassification,
29
+ Trainer,
30
+ TrainingArguments,
31
+ set_seed,
32
+ )
33
+
34
  PROJECT_ROOT = Path(__file__).resolve().parent.parent
35
  if str(PROJECT_ROOT) not in sys.path:
36
  sys.path.insert(0, str(PROJECT_ROOT))
37
 
38
+ from src.config import DATA_RAW_DIR, MODEL_DIR
39
+
40
+
41
+ def parse_args():
42
+ parser = argparse.ArgumentParser(description="Train a transfer-learning vehicle brand classifier.")
43
+ parser.add_argument(
44
+ "--data_dir",
45
+ type=str,
46
+ default="data/raw/Cars Dataset",
47
+ help="Folder with train/ and test/ subfolders containing class folders.",
48
+ )
49
+ parser.add_argument(
50
+ "--base_model",
51
+ type=str,
52
+ default="microsoft/resnet-18",
53
+ help="Base model identifier from Hugging Face.",
54
+ )
55
+ parser.add_argument(
56
+ "--output_dir",
57
+ type=str,
58
+ default="models/car-image-classifier",
59
+ help="Output directory for trained model.",
60
+ )
61
+ parser.add_argument("--epochs", type=int, default=3, help="Number of training epochs.")
62
+ parser.add_argument("--batch_size", type=int, default=64, help="Batch size for training and evaluation.")
63
+ parser.add_argument("--learning_rate", type=float, default=5e-5, help="Learning rate.")
64
+ parser.add_argument("--weight_decay", type=float, default=0.01, help="Weight decay.")
65
+ parser.add_argument("--warmup_ratio", type=float, default=0.1, help="Warmup ratio.")
66
+ parser.add_argument("--label_smoothing", type=float, default=0.1, help="Label smoothing factor.")
67
+ parser.add_argument("--seed", type=int, default=42, help="Random seed.")
68
+ parser.add_argument("--freeze_backbone", action="store_true", help="Freeze backbone and only train head.")
69
+ parser.add_argument("--push_to_hub", action="store_true", help="Push model to Hugging Face Hub.")
70
+ parser.add_argument("--hub_model_id", type=str, default="", help="Hub model ID for pushing.")
71
+ return parser.parse_args()
72
+
73
+
74
+ def build_transforms(processor):
75
+ """Build training and validation transforms based on processor config."""
76
+ image_mean = processor.image_mean
77
+ image_std = processor.image_std
78
+ size_cfg = processor.size
79
+
80
+ # Extract image size from processor config
81
+ if isinstance(size_cfg, dict):
82
+ size = size_cfg.get("shortest_edge") or size_cfg.get("height") or size_cfg.get("width") or 224
83
+ else:
84
+ size = int(size_cfg) if size_cfg else 224
85
+
86
+ from torchvision.transforms import (
87
+ CenterCrop,
88
+ ColorJitter,
89
+ Compose,
90
+ Normalize,
91
+ RandomHorizontalFlip,
92
+ RandomResizedCrop,
93
+ RandomRotation,
94
+ Resize,
95
+ ToTensor,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  )
 
97
 
98
+ train_tfm = Compose(
99
+ [
100
+ RandomResizedCrop(size),
101
+ RandomHorizontalFlip(),
102
+ RandomRotation(15),
103
+ ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2, hue=0.1),
104
+ ToTensor(),
105
+ Normalize(mean=image_mean, std=image_std),
106
+ ]
107
+ )
108
 
109
+ val_tfm = Compose(
110
+ [
111
+ Resize(size),
112
+ CenterCrop(size),
113
+ ToTensor(),
114
+ Normalize(mean=image_mean, std=image_std),
115
+ ]
116
+ )
117
 
118
+ return train_tfm, val_tfm
119
+
120
+
121
+ def main():
122
+ args = parse_args()
123
+ set_seed(args.seed)
124
+ random.seed(args.seed)
125
+ np.random.seed(args.seed)
126
+
127
+ # Load dataset
128
+ data_dir = Path(args.data_dir)
129
+ if not data_dir.exists():
130
+ raise FileNotFoundError(f"Dataset folder not found: {data_dir}")
131
+
132
+ print(f"Loading dataset from {data_dir}...")
133
+ ds = load_dataset("imagefolder", data_dir=str(data_dir))
134
+
135
+ # Ensure we have train and test (or validation)
136
+ if "train" not in ds:
137
+ raise ValueError("Dataset must have a 'train' split (in train/ folder).")
138
+
139
+ if "test" not in ds:
140
+ if "validation" in ds:
141
+ ds["test"] = ds["validation"]
142
+ else:
143
+ # Create test split if only train exists
144
+ split = ds["train"].train_test_split(test_size=0.2, seed=42)
145
+ ds["train"] = split["train"]
146
+ ds["test"] = split["test"]
147
+
148
+ # Get label mapping
149
+ labels = ds["train"].features["label"].names
150
+ label2id = {label: i for i, label in enumerate(labels)}
151
+ id2label = {i: label for i, label in enumerate(labels)}
152
+
153
+ print(f"Classes: {labels}")
154
+ print(f"Number of classes: {len(labels)}")
155
+ print(f"Training samples: {len(ds['train'])}")
156
+ print(f"Test samples: {len(ds['test'])}")
157
+
158
+ # Load processor and model
159
+ print(f"Loading base model: {args.base_model}")
160
+ processor = AutoImageProcessor.from_pretrained(args.base_model)
161
+ model = AutoModelForImageClassification.from_pretrained(
162
+ args.base_model,
163
+ num_labels=len(labels),
164
+ id2label=id2label,
165
+ label2id=label2id,
166
+ ignore_mismatched_sizes=True,
167
+ )
168
+
169
+ # Optionally freeze backbone
170
+ if args.freeze_backbone:
171
+ print("Freezing backbone, only training head...")
172
+ trainable_heads = ("classifier", "score", "fc", "heads", "head")
173
+ for name, param in model.named_parameters():
174
+ if not any(head in name for head in trainable_heads):
175
+ param.requires_grad = False
176
+
177
+ # Build transforms
178
+ train_tfm, val_tfm = build_transforms(processor)
179
+
180
+ def transform_train(batch):
181
+ batch["pixel_values"] = [train_tfm(img.convert("RGB")) for img in batch["image"]]
182
+ return batch
183
+
184
+ def transform_val(batch):
185
+ batch["pixel_values"] = [val_tfm(img.convert("RGB")) for img in batch["image"]]
186
+ return batch
187
+
188
+ ds["train"].set_transform(transform_train)
189
+ ds["test"].set_transform(transform_val)
190
+
191
+ def collate_fn(batch):
192
+ import torch
193
+
194
+ return {
195
+ "pixel_values": torch.stack([example["pixel_values"] for example in batch]),
196
+ "labels": torch.tensor([example["label"] for example in batch]),
197
+ }
198
+
199
+ # Metrics
200
+ metric = evaluate.load("accuracy")
201
+
202
+ def compute_metrics(eval_pred):
203
+ logits, labels_ = eval_pred
204
+ predictions = np.argmax(logits, axis=1)
205
+ return metric.compute(predictions=predictions, references=labels_)
206
+
207
+ # Training arguments
208
+ training_args = TrainingArguments(
209
+ output_dir=args.output_dir,
210
+ remove_unused_columns=False,
211
+ eval_strategy="epoch",
212
+ save_strategy="epoch",
213
+ load_best_model_at_end=True,
214
+ logging_strategy="steps",
215
+ logging_steps=50,
216
+ learning_rate=args.learning_rate,
217
+ weight_decay=args.weight_decay,
218
+ warmup_ratio=args.warmup_ratio,
219
+ label_smoothing_factor=args.label_smoothing,
220
+ per_device_train_batch_size=args.batch_size,
221
+ per_device_eval_batch_size=args.batch_size,
222
+ num_train_epochs=args.epochs,
223
+ push_to_hub=args.push_to_hub,
224
+ hub_model_id=args.hub_model_id if args.hub_model_id else None,
225
+ report_to="none",
226
  )
227
 
228
+ # Trainer
229
+ trainer = Trainer(
230
+ model=model,
231
+ args=training_args,
232
+ train_dataset=ds["train"],
233
+ eval_dataset=ds["test"],
234
+ data_collator=collate_fn,
235
+ compute_metrics=compute_metrics,
236
+ )
237
 
238
+ # Train
239
+ print("Starting training...")
240
+ trainer.train()
 
241
 
242
+ # Evaluate
243
+ print("Evaluating...")
244
+ metrics = trainer.evaluate()
245
+ print(f"Test accuracy: {metrics.get('eval_accuracy', 0):.4f}")
246
 
247
+ # Save model and processor
248
+ print(f"Saving model to {args.output_dir}...")
249
+ trainer.save_model(args.output_dir)
250
+ processor.save_pretrained(args.output_dir)
251
+
252
+ # Generate detailed metrics
253
+ predictions = trainer.predict(ds["test"])
254
+ pred_labels = np.argmax(predictions.predictions, axis=1)
255
+ true_labels = predictions.label_ids
256
+
257
+ report = classification_report(
258
+ true_labels,
259
+ pred_labels,
260
+ target_names=labels,
261
+ output_dict=True,
262
+ zero_division=0,
263
+ )
264
+
265
+ # Save metadata
266
  metadata = {
267
  "created_at": datetime.now(timezone.utc).isoformat(),
268
+ "base_model": args.base_model,
269
+ "number_of_classes": len(labels),
270
+ "class_names": labels,
271
+ "train_image_count": len(ds["train"]),
272
+ "test_image_count": len(ds["test"]),
273
+ "accuracy": float(metrics.get("eval_accuracy", 0)),
274
  "classification_report": report,
275
+ "note": "This model can only predict one of the trained vehicle brands/classes. It does not provide damage detection or technical condition assessment.",
276
  }
 
277
 
278
+ metadata_path = Path(args.output_dir) / "vision_metadata.json"
279
+ metadata_path.parent.mkdir(parents=True, exist_ok=True)
280
+ with open(metadata_path, "w") as f:
281
+ json.dump(metadata, f, indent=2)
282
+
283
+ print(f"Metadata saved to {metadata_path}")
284
+ print("\nTraining complete!")
285
+ print(f"Model saved to {args.output_dir}")
286
+ print(f"Test accuracy: {metrics.get('eval_accuracy', 0):.4f}")
287
+
288
+ if args.push_to_hub:
289
+ trainer.push_to_hub()
290
 
291
 
292
  if __name__ == "__main__":