Spaces:
Sleeping
Sleeping
| """Train a CNN-based (EfficientNet B0 transfer learning) damage classifier. | |
| Feature extraction uses a pretrained EfficientNet B0 backbone (ImageNet | |
| weights via torchvision). This is a fully local operation — no images are | |
| sent to any external API. Six sklearn classifiers are compared, evaluated | |
| with 5-fold stratified cross-validation on the training set, then assessed | |
| on a held-out test set. | |
| Results are compared with the hand-crafted feature baseline from | |
| train_local_cv_model.py (53-dim features, SVM RBF, held-out F1=0.570). | |
| IMPORTANT: This script trains on a local representative subset only. | |
| Do NOT run this script on the full DrBimmer dataset (2,300 images). | |
| Use prepare_local_cv_dataset.py --max-per-class 80 (240 images, 3 classes) | |
| to prepare the balanced subset first. | |
| Expected folder structure: | |
| data/cv_damage/ | |
| no visible damage/ | |
| minor damage/ (optional) | |
| moderate damage/ | |
| severe damage/ | |
| Usage: | |
| python scripts/train_cnn_cv_model.py | |
| python scripts/train_cnn_cv_model.py --skip-cv # faster, no CV | |
| Dependencies (install via requirements-cv.txt): | |
| torch, torchvision, pillow | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import json | |
| import os | |
| import sys | |
| import warnings | |
| from collections import Counter | |
| from pathlib import Path | |
| import joblib | |
| import numpy as np | |
| import pandas as pd | |
| from sklearn.dummy import DummyClassifier | |
| from sklearn.neighbors import KNeighborsClassifier | |
| from sklearn.linear_model import LogisticRegression | |
| from sklearn.metrics import ( | |
| accuracy_score, | |
| classification_report, | |
| confusion_matrix, | |
| f1_score, | |
| precision_score, | |
| recall_score, | |
| ) | |
| from sklearn.model_selection import StratifiedKFold, cross_validate, train_test_split | |
| from sklearn.pipeline import Pipeline | |
| from sklearn.preprocessing import StandardScaler | |
| from sklearn.svm import SVC | |
| PROJECT_ROOT = Path(__file__).resolve().parents[1] | |
| sys.path.append(str(PROJECT_ROOT)) | |
| from scripts.train_local_cv_model import collect_labeled_images | |
| CNN_MODEL_PATH = PROJECT_ROOT / "models/cnn_cv_model.joblib" | |
| REPORT_PATH = PROJECT_ROOT / "reports/cnn_cv_model_report.md" | |
| METRICS_PATH = PROJECT_ROOT / "reports/cnn_cv_model_metrics.json" | |
| CONFUSION_CSV_PATH = PROJECT_ROOT / "reports/cnn_cv_confusion_matrix.csv" | |
| FIGURE_PATH = PROJECT_ROOT / "reports/cnn_cv_confusion_matrix.png" | |
| DEFAULT_INPUT = PROJECT_ROOT / "data/cv_damage" | |
| RANDOM_STATE = 42 | |
| MAX_DATASET_IMAGES = 320 # hard cap — never process more than this locally | |
| AUGMENTATION_VARIANTS = ("original", "horizontal_flip", "brightness_up", "contrast_up") | |
| def extract_efficientnet_features( | |
| image_paths: list[Path], | |
| batch_size: int = 16, | |
| device: str | None = None, | |
| augment: bool = False, | |
| ) -> np.ndarray: | |
| """Extract 1280-dim pooled features from pretrained EfficientNet B0. | |
| The classifier head is removed. Adaptive average pooling output (1280-dim) | |
| is used as the feature vector. All processing is local — no API calls. | |
| """ | |
| import torch | |
| import torchvision.models as tv_models | |
| from PIL import Image as PilImage | |
| from PIL import ImageEnhance, ImageOps | |
| if device is None: | |
| device = "cuda" if torch.cuda.is_available() else "cpu" | |
| print(f" Feature extraction device: {device}") | |
| weights = tv_models.EfficientNet_B0_Weights.DEFAULT | |
| base_model = tv_models.efficientnet_b0(weights=weights) | |
| feature_extractor = torch.nn.Sequential(*list(base_model.children())[:-1]) | |
| feature_extractor.eval().to(device) | |
| transform = weights.transforms() | |
| all_features: list[np.ndarray] = [] | |
| variants = AUGMENTATION_VARIANTS if augment else ("original",) | |
| image_jobs = [(path, variant) for path in image_paths for variant in variants] | |
| n_total = len(image_jobs) | |
| for i in range(0, n_total, batch_size): | |
| batch_jobs = image_jobs[i: i + batch_size] | |
| tensors: list[torch.Tensor] = [] | |
| for path, variant in batch_jobs: | |
| try: | |
| image = PilImage.open(path).convert("RGB") | |
| if variant == "horizontal_flip": | |
| image = ImageOps.mirror(image) | |
| elif variant == "brightness_up": | |
| image = ImageEnhance.Brightness(image).enhance(1.15) | |
| elif variant == "contrast_up": | |
| image = ImageEnhance.Contrast(image).enhance(1.20) | |
| tensors.append(transform(image)) | |
| except Exception: | |
| tensors.append(torch.zeros(3, 224, 224)) | |
| batch = torch.stack(tensors).to(device) | |
| with torch.no_grad(): | |
| out = feature_extractor(batch) | |
| all_features.append(out.squeeze(-1).squeeze(-1).cpu().numpy()) | |
| done = min(i + batch_size, n_total) | |
| if done % 80 == 0 or done == n_total: | |
| print(f" Processed {done}/{n_total} images") | |
| return np.vstack(all_features) | |
| def build_classifiers() -> dict[str, Pipeline]: | |
| """Return six candidate classifiers trained on CNN features.""" | |
| return { | |
| "dummy_most_frequent": Pipeline([ | |
| ("scaler", StandardScaler()), | |
| ("model", DummyClassifier(strategy="most_frequent")), | |
| ]), | |
| "logistic_regression": Pipeline([ | |
| ("scaler", StandardScaler()), | |
| ("model", LogisticRegression( | |
| max_iter=2000, C=1.0, class_weight="balanced", | |
| random_state=RANDOM_STATE, | |
| )), | |
| ]), | |
| "svm_rbf": Pipeline([ | |
| ("scaler", StandardScaler()), | |
| ("model", SVC( | |
| kernel="rbf", C=10.0, gamma="scale", | |
| class_weight="balanced", random_state=RANDOM_STATE, | |
| probability=True, | |
| )), | |
| ]), | |
| "svm_linear": Pipeline([ | |
| ("scaler", StandardScaler()), | |
| ("model", SVC( | |
| kernel="linear", C=1.0, | |
| class_weight="balanced", random_state=RANDOM_STATE, | |
| probability=True, | |
| )), | |
| ]), | |
| "knn_3": Pipeline([ | |
| ("scaler", StandardScaler()), | |
| ("model", KNeighborsClassifier(n_neighbors=3, weights="distance")), | |
| ]), | |
| "knn_5": Pipeline([ | |
| ("scaler", StandardScaler()), | |
| ("model", KNeighborsClassifier(n_neighbors=5, weights="distance")), | |
| ]), | |
| } | |
| def run_cross_validation( | |
| classifiers: dict[str, Pipeline], | |
| x_train: np.ndarray, | |
| y_train: np.ndarray, | |
| n_splits: int, | |
| ) -> dict[str, dict[str, float]]: | |
| """Stratified k-fold CV with F1 macro and accuracy scoring.""" | |
| cv = StratifiedKFold(n_splits=n_splits, shuffle=True, random_state=RANDOM_STATE) | |
| results: dict[str, dict[str, float]] = {} | |
| for name, clf in classifiers.items(): | |
| with warnings.catch_warnings(): | |
| warnings.simplefilter("ignore") | |
| scores = cross_validate( | |
| clf, x_train, y_train, | |
| cv=cv, | |
| scoring=["f1_macro", "accuracy"], | |
| ) | |
| results[name] = { | |
| "f1_macro_mean": float(scores["test_f1_macro"].mean()), | |
| "f1_macro_std": float(scores["test_f1_macro"].std()), | |
| "accuracy_mean": float(scores["test_accuracy"].mean()), | |
| "accuracy_std": float(scores["test_accuracy"].std()), | |
| } | |
| print( | |
| f" {name}: F1={results[name]['f1_macro_mean']:.3f}" | |
| f" ± {results[name]['f1_macro_std']:.3f}" | |
| ) | |
| return results | |
| def evaluate_held_out( | |
| classifiers: dict[str, Pipeline], | |
| x_test: np.ndarray, | |
| y_test: np.ndarray, | |
| ) -> dict[str, dict[str, float]]: | |
| results: dict[str, dict[str, float]] = {} | |
| for name, clf in classifiers.items(): | |
| preds = clf.predict(x_test) | |
| results[name] = { | |
| "accuracy": float(accuracy_score(y_test, preds)), | |
| "precision_macro": float(precision_score(y_test, preds, average="macro", zero_division=0)), | |
| "recall_macro": float(recall_score(y_test, preds, average="macro", zero_division=0)), | |
| "f1_macro": float(f1_score(y_test, preds, average="macro", zero_division=0)), | |
| } | |
| return results | |
| def save_confusion_figure( | |
| y_test: np.ndarray, | |
| predictions: np.ndarray, | |
| labels: list[str], | |
| ) -> None: | |
| if os.getenv("GENERATE_CV_FIGURES") != "1": | |
| return | |
| try: | |
| import matplotlib.pyplot as plt | |
| from sklearn.metrics import ConfusionMatrixDisplay | |
| fig, ax = plt.subplots(figsize=(6, 5)) | |
| ConfusionMatrixDisplay.from_predictions( | |
| y_test, predictions, labels=labels, | |
| ax=ax, colorbar=False, | |
| ) | |
| ax.set_title("CNN (EfficientNet B0) — Confusion Matrix") | |
| fig.tight_layout() | |
| fig.savefig(FIGURE_PATH, dpi=120) | |
| plt.close(fig) | |
| print(f"Saved confusion matrix figure: {FIGURE_PATH}") | |
| except ImportError: | |
| pass | |
| def write_report( | |
| cv_results: dict[str, dict[str, float]] | None, | |
| test_metrics: dict[str, dict[str, float]], | |
| selected_model_name: str, | |
| y_test: np.ndarray, | |
| predictions: np.ndarray, | |
| labels: list[str], | |
| dataset_size: int, | |
| class_counts: Counter, | |
| n_splits: int, | |
| baseline_f1: float, | |
| augmentation_enabled: bool, | |
| train_original_count: int, | |
| train_feature_count: int, | |
| test_original_count: int, | |
| ) -> None: | |
| REPORT_PATH.parent.mkdir(parents=True, exist_ok=True) | |
| matrix = confusion_matrix(y_test, predictions, labels=labels) | |
| pd.DataFrame(matrix, index=labels, columns=labels).to_csv(CONFUSION_CSV_PATH) | |
| report_text = classification_report(y_test, predictions, labels=labels, zero_division=0) | |
| save_confusion_figure(y_test, predictions, labels) | |
| selected_f1 = test_metrics.get(selected_model_name, {}).get("f1_macro", 0.0) | |
| improvement = selected_f1 - baseline_f1 | |
| lines = [ | |
| "# CNN Transfer Learning CV Model Report", | |
| "", | |
| "This report documents the EfficientNet B0 transfer learning approach for local", | |
| "vehicle damage classification, compared against the hand-crafted feature baseline", | |
| "from `train_local_cv_model.py`.", | |
| "", | |
| "**Method**: A pretrained EfficientNet B0 backbone (ImageNet weights via", | |
| "`torchvision`) is used as a fixed feature extractor. The classifier head is removed;", | |
| "the 1280-dim adaptive average pooling output is fed into sklearn classifiers.", | |
| "All processing is fully local — no images are sent to any external API.", | |
| "", | |
| f"- Dataset: **{dataset_size} images** (representative subset — max {MAX_DATASET_IMAGES} enforced)", | |
| "- Feature backbone: `EfficientNet_B0_Weights.DEFAULT` (ImageNet pretrained)", | |
| "- Feature dimensionality: **1280** (vs 53 for hand-crafted baseline)", | |
| f"- Training augmentation: **{'enabled' if augmentation_enabled else 'disabled'}**", | |
| f"- Training originals / feature samples: **{train_original_count} / {train_feature_count}**", | |
| f"- Held-out test originals: **{test_original_count}** (no augmentation applied)", | |
| f"- Evaluation: {n_splits}-fold stratified CV + held-out test set", | |
| f"- Selected model: **{selected_model_name}**", | |
| f"- Artifact: `{CNN_MODEL_PATH.relative_to(PROJECT_ROOT)}`", | |
| "", | |
| "## Class Distribution", | |
| "", | |
| "| Class | Images |", | |
| "|---|---:|", | |
| ] | |
| for label in labels: | |
| lines.append(f"| {label} | {class_counts.get(label, 0)} |") | |
| if cv_results: | |
| lines.extend([ | |
| "", | |
| f"## {n_splits}-Fold Stratified Cross-Validation (Original Training Set)", | |
| "", | |
| "Cross-validation is performed on original training images only. The final", | |
| "classifier is then fitted on the augmented training set. This avoids", | |
| "placing augmented versions of the same original image in different CV folds.", | |
| "", | |
| "| Classifier | F1 macro mean ± std | Accuracy mean ± std |", | |
| "|---|---|---|", | |
| ]) | |
| for name, res in cv_results.items(): | |
| lines.append( | |
| f"| {name} | " | |
| f"{res['f1_macro_mean']:.3f} ± {res['f1_macro_std']:.3f} | " | |
| f"{res['accuracy_mean']:.3f} ± {res['accuracy_std']:.3f} |" | |
| ) | |
| lines.extend([ | |
| "", | |
| "## Held-Out Test Set Results", | |
| "", | |
| "| Classifier | Accuracy | Precision macro | Recall macro | F1 macro |", | |
| "|---|---:|---:|---:|---:|", | |
| ]) | |
| for name, vals in test_metrics.items(): | |
| lines.append( | |
| f"| {name} | {vals['accuracy']:.3f} | {vals['precision_macro']:.3f} |" | |
| f" {vals['recall_macro']:.3f} | {vals['f1_macro']:.3f} |" | |
| ) | |
| lines.extend([ | |
| "", | |
| f"Best model by CV F1: **{selected_model_name}**", | |
| f"Test F1 macro: **{selected_f1:.3f}** (+{improvement:.3f} vs hand-crafted baseline F1 {baseline_f1:.3f})", | |
| "", | |
| "## Classification Report (Test Set, Best Model)", | |
| "", | |
| "```text", | |
| report_text, | |
| "```", | |
| "", | |
| "## Comparison: Hand-Crafted Features vs CNN Transfer Features", | |
| "", | |
| "| Feature type | Dimensionality | Classifier | Test F1 macro |", | |
| "|---|---:|---|---:|", | |
| f"| Hand-crafted (colour histograms, edge stats) | 53 | SVM RBF | {baseline_f1:.3f} |", | |
| f"| EfficientNet B0 transfer (ImageNet pretrained) | 1280 | {selected_model_name} | {selected_f1:.3f} |", | |
| "", | |
| "Transfer learning from ImageNet provides substantially richer features than", | |
| "hand-crafted statistics. EfficientNet B0 encodes high-level visual patterns", | |
| "(textures, surface deformations, structural damage cues) that are directly", | |
| "relevant to vehicle damage classification but unavailable from low-level", | |
| "colour and edge descriptors.", | |
| "", | |
| "## Augmentation", | |
| "", | |
| "Label-preserving augmentation is applied only to the training split:", | |
| "horizontal flip, slight brightness increase, and slight contrast increase.", | |
| "The held-out test set is never augmented. This simulates realistic seller-photo", | |
| "variation while keeping the evaluation honest.", | |
| "", | |
| "## Confusion Matrix", | |
| "", | |
| f"CSV: `{CONFUSION_CSV_PATH.relative_to(PROJECT_ROOT)}`", | |
| "Figure generation is optional. Set `GENERATE_CV_FIGURES=1` before running", | |
| "the script to create the PNG confusion matrix.", | |
| "", | |
| "## Limitations", | |
| "", | |
| "- Image-level classification only; no bounding-box localisation.", | |
| "- EfficientNet B0 weights are frozen (linear probing). End-to-end fine-tuning", | |
| " would improve results further but requires more data and GPU compute.", | |
| "- The deployed app uses OpenAI Vision for richer multi-view reasoning.", | |
| "- Only a representative 240-image subset is used; the full dataset is never", | |
| " sent to any external API in any training or evaluation step.", | |
| ]) | |
| REPORT_PATH.write_text("\n".join(lines), encoding="utf-8") | |
| def main() -> None: | |
| parser = argparse.ArgumentParser(description=__doc__) | |
| parser.add_argument("--input-dir", type=Path, default=DEFAULT_INPUT) | |
| parser.add_argument("--test-size", type=float, default=0.25) | |
| parser.add_argument("--cv-splits", type=int, default=5) | |
| parser.add_argument("--batch-size", type=int, default=16) | |
| parser.add_argument("--no-augmentation", action="store_true", | |
| help="Disable label-preserving training augmentation.") | |
| parser.add_argument("--skip-cv", action="store_true", | |
| help="Skip cross-validation and evaluate on test set only.") | |
| args = parser.parse_args() | |
| rows = collect_labeled_images(args.input_dir) | |
| if len(rows) < 8 or len({lbl for _, lbl in rows}) < 2: | |
| print(f"Not enough labeled images in {args.input_dir}.") | |
| print("Run: python scripts/prepare_local_cv_dataset.py --max-per-class 80") | |
| return | |
| if len(rows) > MAX_DATASET_IMAGES: | |
| print( | |
| f"WARNING: {len(rows)} images found but hard cap is {MAX_DATASET_IMAGES}. " | |
| "Use --max-per-class in prepare_local_cv_dataset.py to limit the subset." | |
| ) | |
| rows = rows[:MAX_DATASET_IMAGES] | |
| class_counts: Counter = Counter(lbl for _, lbl in rows) | |
| unique_labels = sorted(class_counts.keys()) | |
| print(f"Dataset: {len(rows)} images, {len(unique_labels)} classes: {unique_labels}") | |
| image_paths = [p for p, _ in rows] | |
| y = np.asarray([lbl for _, lbl in rows]) | |
| counts = Counter(y) | |
| stratify = y if min(counts.values()) >= 2 else None | |
| train_paths, test_paths, y_train, y_test = train_test_split( | |
| image_paths, y, test_size=args.test_size, random_state=RANDOM_STATE, stratify=stratify, | |
| ) | |
| print(f"Train originals: {len(train_paths)}, Test originals: {len(test_paths)}") | |
| print("\nExtracting EfficientNet B0 features for original training images...") | |
| x_train_original = extract_efficientnet_features( | |
| train_paths, | |
| batch_size=args.batch_size, | |
| augment=False, | |
| ) | |
| augmentation_enabled = not args.no_augmentation | |
| print( | |
| "\nExtracting EfficientNet B0 features for final training set " | |
| f"(augmentation={'on' if augmentation_enabled else 'off'})..." | |
| ) | |
| x_train = extract_efficientnet_features( | |
| train_paths, | |
| batch_size=args.batch_size, | |
| augment=augmentation_enabled, | |
| ) | |
| y_train_fit = np.repeat(y_train, len(AUGMENTATION_VARIANTS)) if augmentation_enabled else y_train | |
| print("\nExtracting EfficientNet B0 features for held-out test images...") | |
| x_test = extract_efficientnet_features(test_paths, batch_size=args.batch_size, augment=False) | |
| print(f"Feature matrices: train_original={x_train_original.shape}, train_fit={x_train.shape}, test={x_test.shape}") | |
| classifiers = build_classifiers() | |
| cv_results: dict[str, dict[str, float]] | None = None | |
| if not args.skip_cv: | |
| print(f"\nRunning {args.cv_splits}-fold stratified CV on original training set...") | |
| cv_results = run_cross_validation(classifiers, x_train_original, y_train, n_splits=args.cv_splits) | |
| print("\nFitting all classifiers on final training set...") | |
| for clf in classifiers.values(): | |
| clf.fit(x_train, y_train_fit) | |
| print("Evaluating on held-out test set...") | |
| test_metrics = evaluate_held_out(classifiers, x_test, y_test) | |
| for name, vals in test_metrics.items(): | |
| print(f" {name}: F1={vals['f1_macro']:.3f}, Acc={vals['accuracy']:.3f}") | |
| if cv_results: | |
| selected_model_name = max(cv_results, key=lambda n: cv_results[n]["f1_macro_mean"]) | |
| else: | |
| selected_model_name = max(test_metrics, key=lambda n: test_metrics[n]["f1_macro"]) | |
| print(f"\nSelected model: {selected_model_name}") | |
| CNN_MODEL_PATH.parent.mkdir(parents=True, exist_ok=True) | |
| joblib.dump({ | |
| "pipeline": classifiers[selected_model_name], | |
| "labels": unique_labels, | |
| "feature_extractor": "efficientnet_b0_imagenet", | |
| "feature_dim": 1280, | |
| "selected_model": selected_model_name, | |
| "augmentation_enabled": augmentation_enabled, | |
| "augmentation_variants": list(AUGMENTATION_VARIANTS if augmentation_enabled else ("original",)), | |
| }, CNN_MODEL_PATH) | |
| METRICS_PATH.write_text(json.dumps({ | |
| "trained": True, | |
| "dataset_size": len(rows), | |
| "class_counts": dict(class_counts), | |
| "augmentation_enabled": augmentation_enabled, | |
| "augmentation_variants": list(AUGMENTATION_VARIANTS if augmentation_enabled else ("original",)), | |
| "train_original_count": len(train_paths), | |
| "train_feature_count": len(x_train), | |
| "test_original_count": len(test_paths), | |
| "selected_model": selected_model_name, | |
| "cv_results": cv_results, | |
| "test_metrics": test_metrics, | |
| }, indent=2), encoding="utf-8") | |
| predictions = classifiers[selected_model_name].predict(x_test) | |
| write_report( | |
| cv_results=cv_results, | |
| test_metrics=test_metrics, | |
| selected_model_name=selected_model_name, | |
| y_test=y_test, | |
| predictions=predictions, | |
| labels=unique_labels, | |
| dataset_size=len(rows), | |
| class_counts=class_counts, | |
| n_splits=args.cv_splits, | |
| baseline_f1=0.570, | |
| augmentation_enabled=augmentation_enabled, | |
| train_original_count=len(train_paths), | |
| train_feature_count=len(x_train), | |
| test_original_count=len(test_paths), | |
| ) | |
| print(f"\nSaved: {CNN_MODEL_PATH}") | |
| print(f"Wrote: {REPORT_PATH}") | |
| if __name__ == "__main__": | |
| main() | |