Spaces:
Sleeping
Sleeping
First Update to the App
Browse files- .gitattributes +2 -0
- app.py +9 -0
- config.yaml +9 -0
- models/best_model.pth +3 -0
- models/comparison/DeepCNN/confusion_matrix.png +0 -0
- models/comparison/ResNet18/confusion_matrix.png +0 -0
- models/comparison/SimpleCNN/confusion_matrix.png +0 -0
- models/comparison/accuracy_comparison.png +0 -0
- models/comparison/failure_analysis_comparison.png +3 -0
- models/comparison/failure_analysis_resnet_vs_simple.png +3 -0
- models/deepcnn_best.pth +3 -0
- models/plots/deepcnn_history.png +0 -0
- models/plots/resnet18_history.png +0 -0
- models/plots/simplecnn_history.png +0 -0
- models/plots/training_history.png +0 -0
- models/resnet18_best.pth +3 -0
- models/simplecnn_best.pth +3 -0
- requirements.txt +9 -0
- src/__init__.py +0 -0
- src/__pycache__/__init__.cpython-312.pyc +0 -0
- src/__pycache__/comparison.cpython-312.pyc +0 -0
- src/__pycache__/dataset.cpython-312.pyc +0 -0
- src/__pycache__/evaluate.cpython-312.pyc +0 -0
- src/__pycache__/model.cpython-312.pyc +0 -0
- src/app.py +326 -0
- src/comparison.py +156 -0
- src/dataset.py +188 -0
- src/evaluate.py +106 -0
- src/model.py +125 -0
- src/train.py +240 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
models/comparison/failure_analysis_comparison.png filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
models/comparison/failure_analysis_resnet_vs_simple.png filter=lfs diff=lfs merge=lfs -text
|
app.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from src.app import build_app
|
| 2 |
+
|
| 3 |
+
# Entry point for Hugging Face Spaces
|
| 4 |
+
app = build_app()
|
| 5 |
+
|
| 6 |
+
if __name__ == "__main__":
|
| 7 |
+
# Note: Hugging Face handles the port and sharing automatically.
|
| 8 |
+
# We do NOT use share=True here.
|
| 9 |
+
app.launch()
|
config.yaml
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
device: "auto" # "cuda", "cpu", or "auto"
|
| 2 |
+
classes: ["glass", "paper", "cardboard", "plastic", "metal", "trash"]
|
| 3 |
+
split: [0.70, 0.15, 0.15]
|
| 4 |
+
img_size: [224, 224]
|
| 5 |
+
batch_size: 32
|
| 6 |
+
epochs: 25
|
| 7 |
+
patience: 5
|
| 8 |
+
lr: 0.001
|
| 9 |
+
random_seed: 42
|
models/best_model.pth
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:b45931b554f896f70e5cfdc7e4cbeff59370a530ca77bda3c16a1f38bb1b5157
|
| 3 |
+
size 44799288
|
models/comparison/DeepCNN/confusion_matrix.png
ADDED
|
models/comparison/ResNet18/confusion_matrix.png
ADDED
|
models/comparison/SimpleCNN/confusion_matrix.png
ADDED
|
models/comparison/accuracy_comparison.png
ADDED
|
models/comparison/failure_analysis_comparison.png
ADDED
|
Git LFS Details
|
models/comparison/failure_analysis_resnet_vs_simple.png
ADDED
|
Git LFS Details
|
models/deepcnn_best.pth
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:a1e5ddb49a5ae181e3279e737d8a44d2c777a24829a6a2d7ef0c5036400a6744
|
| 3 |
+
size 13244572
|
models/plots/deepcnn_history.png
ADDED
|
models/plots/resnet18_history.png
ADDED
|
models/plots/simplecnn_history.png
ADDED
|
models/plots/training_history.png
ADDED
|
models/resnet18_best.pth
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:acb3a9e380f3fa3c5efdd41b9762e0bd3302477beefa9b52ccbd4ebf07568ae0
|
| 3 |
+
size 44799666
|
models/simplecnn_best.pth
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:63d5d6122d38837fb7fede63147cc3b49d98bb83b3e454c151daa673c0a440de
|
| 3 |
+
size 60814
|
requirements.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Production Requirements for Hugging Face Spaces
|
| 2 |
+
torch>=2.0.0
|
| 3 |
+
torchvision
|
| 4 |
+
numpy>=1.24.0
|
| 5 |
+
scikit-learn>=1.3.0
|
| 6 |
+
Pillow
|
| 7 |
+
requests>=2.31.0
|
| 8 |
+
pyyaml
|
| 9 |
+
gradio
|
src/__init__.py
ADDED
|
File without changes
|
src/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (166 Bytes). View file
|
|
|
src/__pycache__/comparison.cpython-312.pyc
ADDED
|
Binary file (8 kB). View file
|
|
|
src/__pycache__/dataset.cpython-312.pyc
ADDED
|
Binary file (9.2 kB). View file
|
|
|
src/__pycache__/evaluate.cpython-312.pyc
ADDED
|
Binary file (4.82 kB). View file
|
|
|
src/__pycache__/model.cpython-312.pyc
ADDED
|
Binary file (6.14 kB). View file
|
|
|
src/app.py
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
import gradio as gr
|
| 5 |
+
import torch
|
| 6 |
+
import yaml
|
| 7 |
+
from torchvision import transforms
|
| 8 |
+
|
| 9 |
+
# Add project root to sys.path
|
| 10 |
+
sys.path.append(str(Path(__file__).parent.parent))
|
| 11 |
+
|
| 12 |
+
from src.model import ResNet18Transfer # noqa: E402
|
| 13 |
+
|
| 14 |
+
# ── Config ───────────────────────────────────────────────────────────────────
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def load_config(config_path="config.yaml"):
|
| 18 |
+
with open(config_path, "r") as f:
|
| 19 |
+
return yaml.safe_load(f)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
config = load_config()
|
| 23 |
+
CLASSES = config["classes"]
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def get_device(cfg_device):
|
| 27 |
+
if cfg_device == "auto":
|
| 28 |
+
return "cuda" if torch.cuda.is_available() else "cpu"
|
| 29 |
+
return cfg_device
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
DEVICE = get_device(config["device"])
|
| 33 |
+
|
| 34 |
+
# ── Model ─────────────────────────────────────────────────────────────────────
|
| 35 |
+
|
| 36 |
+
model = ResNet18Transfer(num_classes=len(CLASSES), pretrained=False)
|
| 37 |
+
model_path = "models/resnet18_best.pth"
|
| 38 |
+
|
| 39 |
+
try:
|
| 40 |
+
model.load_state_dict(torch.load(model_path, map_location=DEVICE, weights_only=True))
|
| 41 |
+
print(f"Loaded model from {model_path}")
|
| 42 |
+
except FileNotFoundError:
|
| 43 |
+
# Fallback to general best_model if specific name is missing
|
| 44 |
+
alt_path = "models/best_model.pth"
|
| 45 |
+
if Path(alt_path).exists():
|
| 46 |
+
model.load_state_dict(torch.load(alt_path, map_location=DEVICE, weights_only=True))
|
| 47 |
+
print(f"Loaded model from {alt_path}")
|
| 48 |
+
else:
|
| 49 |
+
print("Warning: Model checkpoints not found. Using untrained model.")
|
| 50 |
+
|
| 51 |
+
model.to(DEVICE)
|
| 52 |
+
model.eval()
|
| 53 |
+
|
| 54 |
+
# ── Transform ─────────────────────────────────────────────────────────────────
|
| 55 |
+
|
| 56 |
+
transform = transforms.Compose(
|
| 57 |
+
[
|
| 58 |
+
transforms.Resize((224, 224)),
|
| 59 |
+
transforms.ToTensor(),
|
| 60 |
+
transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
|
| 61 |
+
]
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
# ── Translations ──────────────────────────────────────────────────────────────
|
| 65 |
+
|
| 66 |
+
TRANSLATIONS = {
|
| 67 |
+
"en": {
|
| 68 |
+
"title": "🗑️ Trash Classifier Pro",
|
| 69 |
+
"description": "Enterprise-grade waste classification powered by Deep Learning.", # noqa: E501
|
| 70 |
+
"input_label": "Waste Image Upload",
|
| 71 |
+
"output_label": "Classification Analysis",
|
| 72 |
+
"btn_lang": "🇩🇪 Deutsch",
|
| 73 |
+
"btn_classify": "🔍 Run Analysis",
|
| 74 |
+
"no_image": "⚠️ Please upload an image first.",
|
| 75 |
+
"info_header": "Information Hub",
|
| 76 |
+
"model_details": (
|
| 77 |
+
"### Model Information\n"
|
| 78 |
+
"- **Architecture:** ResNet18 (Transfer Learning)\n"
|
| 79 |
+
"- **Accuracy:** 92.4% on test set\n"
|
| 80 |
+
"- **Framework:** PyTorch 2.x\n"
|
| 81 |
+
"- **Backend:** CPU/GPU automated switching"
|
| 82 |
+
),
|
| 83 |
+
"instructions": (
|
| 84 |
+
"### How to use\n"
|
| 85 |
+
"1. Upload a clear photo of an item.\n"
|
| 86 |
+
"2. The model will analyze texture and shape.\n"
|
| 87 |
+
"3. View the confidence scores and recycling tips."
|
| 88 |
+
),
|
| 89 |
+
"tips_header": "Recycling Tip",
|
| 90 |
+
"tips": {
|
| 91 |
+
"glass": "Glass is 100% recyclable. Please remove caps and rinse containers.", # noqa: E501
|
| 92 |
+
"paper": "Avoid recycling paper contaminated with food (like pizza boxes).", # noqa: E501
|
| 93 |
+
"cardboard": "Flatten boxes to save space in the recycling bin.",
|
| 94 |
+
"plastic": "Check the recycling code. Rinse to avoid contamination.",
|
| 95 |
+
"metal": "Aluminum and steel cans are highly valuable for recycling.",
|
| 96 |
+
"trash": "This item belongs in general waste. Check local disposal rules.", # noqa: E501
|
| 97 |
+
},
|
| 98 |
+
"class_names": {
|
| 99 |
+
"glass": "Glass",
|
| 100 |
+
"paper": "Paper",
|
| 101 |
+
"cardboard": "Cardboard",
|
| 102 |
+
"plastic": "Plastic",
|
| 103 |
+
"metal": "Metal",
|
| 104 |
+
"trash": "General Waste",
|
| 105 |
+
},
|
| 106 |
+
},
|
| 107 |
+
"de": {
|
| 108 |
+
"title": "🗑️ Müll-Klassifikator Pro",
|
| 109 |
+
"description": "Professionelle Abfallklassifizierung basierend auf Deep Learning.", # noqa: E501
|
| 110 |
+
"input_label": "Müllbild hochladen",
|
| 111 |
+
"output_label": "Klassifikations-Analyse",
|
| 112 |
+
"btn_lang": "🇬🇧 English",
|
| 113 |
+
"btn_classify": "🔍 Analyse starten",
|
| 114 |
+
"no_image": "⚠️ Bitte zuerst ein Bild hochladen.",
|
| 115 |
+
"info_header": "Informationszentrum",
|
| 116 |
+
"model_details": (
|
| 117 |
+
"### Modell-Informationen\n"
|
| 118 |
+
"- **Architektur:** ResNet18 (Transfer Learning)\n"
|
| 119 |
+
"- **Genauigkeit:** 92,4% auf dem Test-Set\n"
|
| 120 |
+
"- **Framework:** PyTorch 2.x\n"
|
| 121 |
+
"- **Backend:** Automatische CPU/GPU Umschaltung"
|
| 122 |
+
),
|
| 123 |
+
"instructions": (
|
| 124 |
+
"### Anleitung\n"
|
| 125 |
+
"1. Lade ein scharfes Foto eines Gegenstands hoch.\n"
|
| 126 |
+
"2. Das Modell analysiert Textur und Form.\n"
|
| 127 |
+
"3. Sieh dir die Konfidenzwerte und Recycling-Tipps an."
|
| 128 |
+
),
|
| 129 |
+
"tips_header": "Recycling-Tipp",
|
| 130 |
+
"tips": {
|
| 131 |
+
"glass": "Glas ist zu 100% recycelbar. Bitte Deckel entfernen und Behälter ausspülen.", # noqa: E501
|
| 132 |
+
"paper": "Vermeide das Recycling von verschmutztem Papier (z.B. Pizzakartons).", # noqa: E501
|
| 133 |
+
"cardboard": "Kartons flachdrücken, um Platz in der Tonne zu sparen.",
|
| 134 |
+
"plastic": "Prüfe den Recycling-Code. Ausspülen verhindert Kontamination.", # noqa: E501
|
| 135 |
+
"metal": "Alu- und Stahlmüll ist sehr wertvoll für das Recycling.",
|
| 136 |
+
"trash": "Dieser Gegenstand gehört in den Restmüll. Prüfe lokale Regeln.", # noqa: E501
|
| 137 |
+
},
|
| 138 |
+
"class_names": {
|
| 139 |
+
"glass": "Glas",
|
| 140 |
+
"paper": "Papier",
|
| 141 |
+
"cardboard": "Pappe",
|
| 142 |
+
"plastic": "Plastik",
|
| 143 |
+
"metal": "Metall",
|
| 144 |
+
"trash": "Restmüll",
|
| 145 |
+
},
|
| 146 |
+
},
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
# ── Inference ─────────────────────────────────────────────────────────────────
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def predict(image, lang="en"):
|
| 154 |
+
t = TRANSLATIONS[lang]
|
| 155 |
+
|
| 156 |
+
if image is None:
|
| 157 |
+
return {}, t["no_image"]
|
| 158 |
+
|
| 159 |
+
img_tensor = transform(image).unsqueeze(0).to(DEVICE)
|
| 160 |
+
|
| 161 |
+
with torch.no_grad():
|
| 162 |
+
outputs = model(img_tensor)
|
| 163 |
+
probs = torch.nn.functional.softmax(outputs[0], dim=0)
|
| 164 |
+
|
| 165 |
+
# Dictionary for gr.Label
|
| 166 |
+
confidences = {}
|
| 167 |
+
for i, prob in enumerate(probs):
|
| 168 |
+
class_key = CLASSES[i]
|
| 169 |
+
class_name = t["class_names"].get(class_key, class_key)
|
| 170 |
+
confidences[class_name] = float(prob)
|
| 171 |
+
|
| 172 |
+
# Get tip for top class
|
| 173 |
+
top_class_idx = torch.argmax(probs).item()
|
| 174 |
+
top_class_key = CLASSES[top_class_idx]
|
| 175 |
+
tip = t["tips"].get(top_class_key, "")
|
| 176 |
+
tip_md = f"### {t['tips_header']}\n{tip}"
|
| 177 |
+
|
| 178 |
+
return confidences, tip_md
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
# ── UI ────────────────────────────────────────────────────────────────────────
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def build_app():
|
| 185 |
+
with gr.Blocks() as app:
|
| 186 |
+
lang_state = gr.State("en")
|
| 187 |
+
|
| 188 |
+
with gr.Column(elem_classes="container"):
|
| 189 |
+
with gr.Row():
|
| 190 |
+
with gr.Column(scale=8):
|
| 191 |
+
pass
|
| 192 |
+
with gr.Column(scale=2):
|
| 193 |
+
lang_btn = gr.Button(
|
| 194 |
+
TRANSLATIONS["en"]["btn_lang"], variant="secondary", size="sm"
|
| 195 |
+
)
|
| 196 |
+
|
| 197 |
+
# Custom Header
|
| 198 |
+
with gr.Column(elem_classes="header"):
|
| 199 |
+
title_md = gr.Markdown(f"# {TRANSLATIONS['en']['title']}")
|
| 200 |
+
desc_md = gr.Markdown(TRANSLATIONS["en"]["description"])
|
| 201 |
+
|
| 202 |
+
with gr.Row(variant="panel"):
|
| 203 |
+
with gr.Column(scale=1):
|
| 204 |
+
image_input = gr.Image(
|
| 205 |
+
type="pil",
|
| 206 |
+
label=TRANSLATIONS["en"]["input_label"],
|
| 207 |
+
height=450,
|
| 208 |
+
)
|
| 209 |
+
classify_btn = gr.Button(
|
| 210 |
+
TRANSLATIONS["en"]["btn_classify"], variant="primary", size="lg"
|
| 211 |
+
)
|
| 212 |
+
|
| 213 |
+
with gr.Accordion(TRANSLATIONS["en"]["info_header"], open=True) as info_acc:
|
| 214 |
+
info_instructions = gr.Markdown(
|
| 215 |
+
TRANSLATIONS["en"]["instructions"], elem_classes="info-card"
|
| 216 |
+
)
|
| 217 |
+
info_model = gr.Markdown(TRANSLATIONS["en"]["model_details"])
|
| 218 |
+
|
| 219 |
+
with gr.Column(scale=1):
|
| 220 |
+
result_label_md = gr.Markdown(f"## {TRANSLATIONS['en']['output_label']}")
|
| 221 |
+
result_output = gr.Label(
|
| 222 |
+
num_top_classes=3,
|
| 223 |
+
label="",
|
| 224 |
+
)
|
| 225 |
+
tip_output = gr.Markdown("", elem_classes="tip-card")
|
| 226 |
+
|
| 227 |
+
# ── Language toggle ──────────────────────────────────────────────────
|
| 228 |
+
def toggle_language(current_lang):
|
| 229 |
+
new_lang = "de" if current_lang == "en" else "en"
|
| 230 |
+
t = TRANSLATIONS[new_lang]
|
| 231 |
+
return (
|
| 232 |
+
new_lang,
|
| 233 |
+
t["btn_lang"],
|
| 234 |
+
f"# {t['title']}",
|
| 235 |
+
t["description"],
|
| 236 |
+
gr.update(label=t["input_label"]),
|
| 237 |
+
t["btn_classify"],
|
| 238 |
+
f"## {t['output_label']}",
|
| 239 |
+
gr.update(label=t["info_header"]),
|
| 240 |
+
t["instructions"],
|
| 241 |
+
t["model_details"],
|
| 242 |
+
"", # Reset tip
|
| 243 |
+
)
|
| 244 |
+
|
| 245 |
+
lang_btn.click(
|
| 246 |
+
fn=toggle_language,
|
| 247 |
+
inputs=[lang_state],
|
| 248 |
+
outputs=[
|
| 249 |
+
lang_state,
|
| 250 |
+
lang_btn,
|
| 251 |
+
title_md,
|
| 252 |
+
desc_md,
|
| 253 |
+
image_input,
|
| 254 |
+
classify_btn,
|
| 255 |
+
result_label_md,
|
| 256 |
+
info_acc,
|
| 257 |
+
info_instructions,
|
| 258 |
+
info_model,
|
| 259 |
+
tip_output,
|
| 260 |
+
],
|
| 261 |
+
)
|
| 262 |
+
|
| 263 |
+
# ── Classify ─────────────────────────────────────────────────────────
|
| 264 |
+
classify_btn.click(
|
| 265 |
+
fn=predict,
|
| 266 |
+
inputs=[image_input, lang_state],
|
| 267 |
+
outputs=[result_output, tip_output],
|
| 268 |
+
)
|
| 269 |
+
|
| 270 |
+
image_input.change(
|
| 271 |
+
fn=predict,
|
| 272 |
+
inputs=[image_input, lang_state],
|
| 273 |
+
outputs=[result_output, tip_output],
|
| 274 |
+
)
|
| 275 |
+
|
| 276 |
+
return app
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
if __name__ == "__main__":
|
| 280 |
+
app = build_app()
|
| 281 |
+
|
| 282 |
+
# Gradio 6.0 Styling Parameters
|
| 283 |
+
theme = gr.themes.Soft(primary_hue="emerald", spacing_size="lg", radius_size="lg")
|
| 284 |
+
css = """
|
| 285 |
+
.container { max-width: 1200px; margin: auto; padding: 20px; }
|
| 286 |
+
.header {
|
| 287 |
+
text-align: center;
|
| 288 |
+
padding: 40px 20px;
|
| 289 |
+
background: linear-gradient(135deg, #065f46 0%, #059669 100%);
|
| 290 |
+
color: white !important;
|
| 291 |
+
border-radius: 20px;
|
| 292 |
+
margin-bottom: 30px;
|
| 293 |
+
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
| 294 |
+
}
|
| 295 |
+
.header h1, .header p { color: white !important; }
|
| 296 |
+
|
| 297 |
+
/* Adapt cards to theme colors */
|
| 298 |
+
.info-card {
|
| 299 |
+
background-color: var(--background-fill-secondary);
|
| 300 |
+
border-left: 5px solid #10b981;
|
| 301 |
+
padding: 20px;
|
| 302 |
+
border-radius: 10px;
|
| 303 |
+
color: var(--body-text-color);
|
| 304 |
+
}
|
| 305 |
+
.tip-card {
|
| 306 |
+
background-color: var(--warning-100);
|
| 307 |
+
border-left: 5px solid #f59e0b;
|
| 308 |
+
padding: 20px;
|
| 309 |
+
border-radius: 10px;
|
| 310 |
+
margin-top: 20px;
|
| 311 |
+
color: #92400e;
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
/* Dark mode overrides for cards */
|
| 315 |
+
[data-theme='dark'] .tip-card {
|
| 316 |
+
background-color: #451a03;
|
| 317 |
+
color: #fef3c7;
|
| 318 |
+
border-left-color: #d97706;
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
.gr-label-text { font-weight: bold; }
|
| 322 |
+
""" # noqa: E501
|
| 323 |
+
|
| 324 |
+
# inbrowser=True opens the browser automatically
|
| 325 |
+
# share=True provides a public URL
|
| 326 |
+
app.launch(inbrowser=True, theme=theme, css=css, share=True)
|
src/comparison.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
# Add project root to sys.path
|
| 6 |
+
sys.path.append(str(Path(__file__).parent.parent))
|
| 7 |
+
|
| 8 |
+
import matplotlib.pyplot as plt # noqa: E402
|
| 9 |
+
import numpy as np # noqa: E402
|
| 10 |
+
import torch # noqa: E402
|
| 11 |
+
import yaml # noqa: E402
|
| 12 |
+
from torch.utils.data import DataLoader # noqa: E402
|
| 13 |
+
from torchvision import transforms # noqa: E402
|
| 14 |
+
|
| 15 |
+
from src.dataset import TrashDataset # noqa: E402
|
| 16 |
+
from src.evaluate import evaluate # noqa: E402
|
| 17 |
+
from src.model import DeepCNN, ResNet18Transfer, SimpleCNN # noqa: E402
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def load_config(config_path="config.yaml"):
|
| 21 |
+
with open(config_path, "r") as f:
|
| 22 |
+
return yaml.safe_load(f)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def run_comparison():
|
| 26 |
+
config = load_config()
|
| 27 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 28 |
+
processed_dir = Path("data/processed")
|
| 29 |
+
save_dir = Path("models/comparison")
|
| 30 |
+
save_dir.mkdir(parents=True, exist_ok=True)
|
| 31 |
+
|
| 32 |
+
# Normalization MUST match training
|
| 33 |
+
test_transform = transforms.Compose(
|
| 34 |
+
[
|
| 35 |
+
transforms.ToPILImage(),
|
| 36 |
+
transforms.ToTensor(),
|
| 37 |
+
transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
|
| 38 |
+
]
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
# Load test data with transforms
|
| 42 |
+
test_ds = TrashDataset(
|
| 43 |
+
processed_dir / "X_test.npy", processed_dir / "y_test.npy", transform=test_transform
|
| 44 |
+
)
|
| 45 |
+
test_loader = DataLoader(test_ds, batch_size=config["batch_size"], shuffle=False)
|
| 46 |
+
|
| 47 |
+
models = {
|
| 48 |
+
"SimpleCNN": SimpleCNN(num_classes=len(config["classes"])),
|
| 49 |
+
"DeepCNN": DeepCNN(num_classes=len(config["classes"])),
|
| 50 |
+
"ResNet18": ResNet18Transfer(num_classes=len(config["classes"]), pretrained=False),
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
# Map model names to their best saved checkpoints
|
| 54 |
+
checkpoint_map = {
|
| 55 |
+
"SimpleCNN": "models/simplecnn_best.pth",
|
| 56 |
+
"DeepCNN": "models/deepcnn_best.pth",
|
| 57 |
+
"ResNet18": "models/resnet18_best.pth",
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
results = {}
|
| 61 |
+
all_preds = {}
|
| 62 |
+
|
| 63 |
+
for name, model in models.items():
|
| 64 |
+
print(f"\nEvaluating {name}...")
|
| 65 |
+
model_path = checkpoint_map[name]
|
| 66 |
+
|
| 67 |
+
if os.path.exists(model_path):
|
| 68 |
+
model.load_state_dict(torch.load(model_path, map_location=device))
|
| 69 |
+
print(f"Loaded weights from {model_path}")
|
| 70 |
+
else:
|
| 71 |
+
print(f"Warning: {model_path} not found. Using untrained weights for {name}.")
|
| 72 |
+
|
| 73 |
+
loss, acc = evaluate(model, test_loader, device=device, save_dir=str(save_dir / name))
|
| 74 |
+
results[name] = acc
|
| 75 |
+
|
| 76 |
+
# Collect all predictions for failure analysis
|
| 77 |
+
model.to(device)
|
| 78 |
+
model.eval()
|
| 79 |
+
preds = []
|
| 80 |
+
with torch.no_grad():
|
| 81 |
+
for images, _ in test_loader:
|
| 82 |
+
outputs = model(images.to(device))
|
| 83 |
+
preds.extend(torch.max(outputs, 1)[1].cpu().numpy())
|
| 84 |
+
all_preds[name] = np.array(preds)
|
| 85 |
+
|
| 86 |
+
# 1. Accuracy Comparison Plot
|
| 87 |
+
fig, ax = plt.subplots(figsize=(8, 5))
|
| 88 |
+
bars = ax.bar(
|
| 89 |
+
results.keys(),
|
| 90 |
+
results.values(),
|
| 91 |
+
color=["#4C9BE8", "#5DBB63", "#E8714C"],
|
| 92 |
+
width=0.5,
|
| 93 |
+
edgecolor="white",
|
| 94 |
+
linewidth=1.2,
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
# Write value directly onto the bars
|
| 98 |
+
for bar, val in zip(bars, results.values()):
|
| 99 |
+
ax.text(
|
| 100 |
+
bar.get_x() + bar.get_width() / 2,
|
| 101 |
+
bar.get_height() + 0.01,
|
| 102 |
+
f"{val*100:.1f}%",
|
| 103 |
+
ha="center",
|
| 104 |
+
va="bottom",
|
| 105 |
+
fontsize=12,
|
| 106 |
+
fontweight="bold",
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
ax.set_ylabel("Test Accuracy", fontsize=12)
|
| 110 |
+
ax.set_title("Model Accuracy Comparison — TrashNet", fontsize=14, fontweight="bold")
|
| 111 |
+
ax.set_ylim(0, 1.05)
|
| 112 |
+
ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda y, _: f"{y*100:.0f}%"))
|
| 113 |
+
ax.spines["top"].set_visible(False)
|
| 114 |
+
ax.spines["right"].set_visible(False)
|
| 115 |
+
plt.tight_layout()
|
| 116 |
+
plt.savefig(save_dir / "accuracy_comparison.png", dpi=150)
|
| 117 |
+
print(f"\nComparison plot saved to {save_dir}/accuracy_comparison.png")
|
| 118 |
+
|
| 119 |
+
# 2. Failure Analysis
|
| 120 |
+
y_test = np.load(processed_dir / "y_test.npy")
|
| 121 |
+
resnet_correct = all_preds["ResNet18"] == y_test
|
| 122 |
+
deep_wrong = all_preds["DeepCNN"] != y_test
|
| 123 |
+
simple_wrong = all_preds["SimpleCNN"] != y_test
|
| 124 |
+
|
| 125 |
+
interesting_indices = np.where(resnet_correct & deep_wrong & simple_wrong)[0]
|
| 126 |
+
|
| 127 |
+
if len(interesting_indices) > 0:
|
| 128 |
+
num_interesting = len(interesting_indices)
|
| 129 |
+
print(f"Failure Analysis: Found {num_interesting} samples.")
|
| 130 |
+
X_test = np.load(processed_dir / "X_test.npy")
|
| 131 |
+
|
| 132 |
+
num_show = min(5, len(interesting_indices))
|
| 133 |
+
fig, axes = plt.subplots(1, num_show, figsize=(4 * num_show, 4))
|
| 134 |
+
if num_show == 1:
|
| 135 |
+
axes = [axes]
|
| 136 |
+
|
| 137 |
+
for i in range(num_show):
|
| 138 |
+
idx = interesting_indices[i]
|
| 139 |
+
axes[i].imshow(X_test[idx])
|
| 140 |
+
true_label = config["classes"][y_test[idx]]
|
| 141 |
+
deep_pred = config["classes"][all_preds["DeepCNN"][idx]]
|
| 142 |
+
simple_pred = config["classes"][all_preds["SimpleCNN"][idx]]
|
| 143 |
+
axes[i].set_title(
|
| 144 |
+
f"True: {true_label}\nResNet: ✓\nDeep: {deep_pred}\nSimple: {simple_pred}",
|
| 145 |
+
fontsize=10,
|
| 146 |
+
pad=4,
|
| 147 |
+
)
|
| 148 |
+
axes[i].axis("off")
|
| 149 |
+
|
| 150 |
+
plt.subplots_adjust(top=0.85, wspace=0.1)
|
| 151 |
+
plt.savefig(save_dir / "failure_analysis_comparison.png", dpi=150, bbox_inches="tight")
|
| 152 |
+
print(f"Failure analysis plot saved to {save_dir}/failure_analysis_comparison.png")
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
if __name__ == "__main__":
|
| 156 |
+
run_comparison()
|
src/dataset.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Download and prepare TrashNet dataset from GitHub.
|
| 3 |
+
|
| 4 |
+
Fetches the dataset-resized.zip, converts all images to numpy arrays
|
| 5 |
+
with consistent size, and splits into train/val/test sets.
|
| 6 |
+
|
| 7 |
+
Saved files:
|
| 8 |
+
data/processed/X_train.npy, y_train.npy
|
| 9 |
+
data/processed/X_val.npy, y_val.npy
|
| 10 |
+
data/processed/X_test.npy, y_test.npy
|
| 11 |
+
data/processed/classes.npy
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import io
|
| 15 |
+
import os
|
| 16 |
+
import sys
|
| 17 |
+
import zipfile
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
|
| 20 |
+
import numpy as np
|
| 21 |
+
import requests
|
| 22 |
+
import torch
|
| 23 |
+
import yaml
|
| 24 |
+
from PIL import Image
|
| 25 |
+
from sklearn.model_selection import train_test_split
|
| 26 |
+
from torch.utils.data import Dataset
|
| 27 |
+
|
| 28 |
+
# Add the project root to the python path
|
| 29 |
+
sys.path.append(str(Path(__file__).parent.parent))
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def load_config(config_path="config.yaml"):
|
| 33 |
+
with open(config_path, "r") as f:
|
| 34 |
+
return yaml.safe_load(f)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
config = load_config()
|
| 38 |
+
CLASSES = config["classes"]
|
| 39 |
+
SPLIT = config["split"]
|
| 40 |
+
IMG_SIZE = tuple(config["img_size"])
|
| 41 |
+
RANDOM_SEED = config["random_seed"]
|
| 42 |
+
|
| 43 |
+
os.makedirs(name="data", exist_ok=True)
|
| 44 |
+
|
| 45 |
+
GITHUB_URL = "https://github.com/garythung/trashnet/raw/master/data/dataset-resized.zip"
|
| 46 |
+
RAW_DIR = Path("data/raw")
|
| 47 |
+
OUT_DIR = Path("data/processed")
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def download_and_extract() -> Path:
|
| 51 |
+
"""
|
| 52 |
+
Automates data acquisition for reproducibility.
|
| 53 |
+
Downloads the TrashNet ZIP from GitHub and extracts it to data/raw/.
|
| 54 |
+
This ensures that anyone running the script gets the exact same starting data.
|
| 55 |
+
"""
|
| 56 |
+
if (RAW_DIR / "dataset-resized").exists():
|
| 57 |
+
print("[SKIP] Already extracted.")
|
| 58 |
+
return RAW_DIR
|
| 59 |
+
|
| 60 |
+
print("[DOWNLOAD] TrashNet from GitHub...")
|
| 61 |
+
|
| 62 |
+
try:
|
| 63 |
+
response = requests.get(GITHUB_URL, timeout=120)
|
| 64 |
+
response.raise_for_status()
|
| 65 |
+
|
| 66 |
+
with zipfile.ZipFile(io.BytesIO(response.content)) as zf:
|
| 67 |
+
zf.extractall(RAW_DIR)
|
| 68 |
+
print("[OK] Extraction done.")
|
| 69 |
+
except Exception as e:
|
| 70 |
+
print(f"[ERROR] Download failed: {e}")
|
| 71 |
+
raise
|
| 72 |
+
|
| 73 |
+
return RAW_DIR
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def load_images(raw_path: Path) -> tuple[np.ndarray, np.ndarray]:
|
| 77 |
+
"""
|
| 78 |
+
Data Standardization:
|
| 79 |
+
Reads all images per class, resizes them to a uniform size (IMG_SIZE),
|
| 80 |
+
and converts them to RGB. This creates a consistent input format for
|
| 81 |
+
the neural network, regardless of the original image dimensions or formats.
|
| 82 |
+
"""
|
| 83 |
+
images, labels = [], []
|
| 84 |
+
|
| 85 |
+
for label_index, class_name in enumerate(CLASSES):
|
| 86 |
+
class_dir = raw_path / "dataset-resized" / class_name
|
| 87 |
+
if not class_dir.exists():
|
| 88 |
+
print(f"[WARN] Folder not found: {class_dir}, skipping.")
|
| 89 |
+
continue
|
| 90 |
+
|
| 91 |
+
files = list(class_dir.glob("*.jpg")) + list(class_dir.glob("*.png"))
|
| 92 |
+
print(f"[LOAD] {class_name}: {len(files)} images")
|
| 93 |
+
|
| 94 |
+
for img_path in files:
|
| 95 |
+
try:
|
| 96 |
+
img = Image.open(img_path).convert("RGB")
|
| 97 |
+
img = img.resize(IMG_SIZE)
|
| 98 |
+
images.append(np.array(img, dtype=np.uint8))
|
| 99 |
+
labels.append(label_index)
|
| 100 |
+
except Exception as e:
|
| 101 |
+
print(f"[WARN] Could not read {img_path}: {e}")
|
| 102 |
+
|
| 103 |
+
return np.array(images), np.array(labels, dtype=np.int64)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def split_and_save(images: np.ndarray, labels: np.ndarray):
|
| 107 |
+
"""
|
| 108 |
+
Evaluation Rigor:
|
| 109 |
+
Splits the data into fixed Train, Validation, and Test sets.
|
| 110 |
+
- Train: Used to update model weights.
|
| 111 |
+
- Val: Used to tune hyperparameters and prevent overfitting.
|
| 112 |
+
- Test: Used for final unbiased evaluation.
|
| 113 |
+
Saving as .npy files makes loading much faster during training.
|
| 114 |
+
"""
|
| 115 |
+
|
| 116 |
+
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
| 117 |
+
train_ratio, val_ratio, test_ratio = SPLIT
|
| 118 |
+
|
| 119 |
+
X_train, X_rest, y_train, y_rest = train_test_split(
|
| 120 |
+
images, labels, test_size=(1 - train_ratio), stratify=labels, random_state=RANDOM_SEED
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
val_size = val_ratio / (val_ratio + test_ratio)
|
| 124 |
+
X_val, X_test, y_val, y_test = train_test_split(
|
| 125 |
+
X_rest, y_rest, test_size=(1 - val_size), stratify=y_rest, random_state=RANDOM_SEED
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
splits = {
|
| 129 |
+
"X_train": X_train,
|
| 130 |
+
"y_train": y_train,
|
| 131 |
+
"X_val": X_val,
|
| 132 |
+
"y_val": y_val,
|
| 133 |
+
"X_test": X_test,
|
| 134 |
+
"y_test": y_test,
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
for name, array in splits.items():
|
| 138 |
+
path = OUT_DIR / f"{name}.npy"
|
| 139 |
+
np.save(path, array)
|
| 140 |
+
print(f"[OK] {name}.npy → {array.shape} dtype={array.dtype}")
|
| 141 |
+
|
| 142 |
+
np.save(OUT_DIR / "classes.npy", np.array(CLASSES))
|
| 143 |
+
print(f"\n[DONE] Splits: " f"Train={len(y_train)} | Val={len(y_val)} | Test={len(y_test)}")
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
class TrashDataset(Dataset):
|
| 147 |
+
"""
|
| 148 |
+
The Bridge to PyTorch:
|
| 149 |
+
This class is REQUIRED because PyTorch's DataLoader expects a Dataset object.
|
| 150 |
+
|
| 151 |
+
Why this class?
|
| 152 |
+
1. Efficient Loading: It only loads specific images into RAM when needed (lazy loading).
|
| 153 |
+
2. Data Augmentation: Allows on-the-fly transformations (rotation, flip, etc.) in __getitem__.
|
| 154 |
+
3. Tensor Conversion: Handles the conversion from NumPy arrays to PyTorch Tensors.
|
| 155 |
+
"""
|
| 156 |
+
|
| 157 |
+
def __init__(self, x_path: Path, y_path: Path, transform=None):
|
| 158 |
+
"""Loads the pre-processed .npy files once into memory."""
|
| 159 |
+
self.X = np.load(x_path)
|
| 160 |
+
self.y = torch.from_numpy(np.load(y_path))
|
| 161 |
+
self.transform = transform
|
| 162 |
+
|
| 163 |
+
def __len__(self):
|
| 164 |
+
"""Tells the DataLoader how many samples are in the dataset."""
|
| 165 |
+
return len(self.X)
|
| 166 |
+
|
| 167 |
+
def __getitem__(self, idx):
|
| 168 |
+
"""
|
| 169 |
+
Fetches a single sample (image + label) at the given index.
|
| 170 |
+
This is where preprocessing (transforms) happens during training.
|
| 171 |
+
"""
|
| 172 |
+
img = self.X[idx]
|
| 173 |
+
label = self.y[idx]
|
| 174 |
+
|
| 175 |
+
if self.transform:
|
| 176 |
+
img = self.transform(img)
|
| 177 |
+
else:
|
| 178 |
+
# Default: Convert [H, W, C] (0-255) to [C, H, W] (0.0-1.0) for
|
| 179 |
+
# PyTorch
|
| 180 |
+
img = torch.from_numpy(img).permute(2, 0, 1).float() / 255.0
|
| 181 |
+
|
| 182 |
+
return img, label
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
if __name__ == "__main__":
|
| 186 |
+
raw_path = download_and_extract()
|
| 187 |
+
images, labels = load_images(raw_path)
|
| 188 |
+
split_and_save(images, labels)
|
src/evaluate.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
# Add project root to sys.path
|
| 6 |
+
sys.path.append(str(Path(__file__).parent.parent))
|
| 7 |
+
|
| 8 |
+
import matplotlib # noqa: E402
|
| 9 |
+
|
| 10 |
+
matplotlib.use("Agg") # Use headless backend
|
| 11 |
+
import matplotlib.pyplot as plt # noqa: E402
|
| 12 |
+
import torch # noqa: E402
|
| 13 |
+
import torch.nn as nn # noqa: E402
|
| 14 |
+
import yaml # noqa: E402
|
| 15 |
+
from sklearn.metrics import ( # noqa: E402
|
| 16 |
+
ConfusionMatrixDisplay,
|
| 17 |
+
classification_report,
|
| 18 |
+
confusion_matrix,
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def load_config(config_path="config.yaml"):
|
| 23 |
+
with open(config_path, "r") as f:
|
| 24 |
+
return yaml.safe_load(f)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
config = load_config()
|
| 28 |
+
CLASSES = config["classes"]
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def get_device(config_device):
|
| 32 |
+
if config_device == "auto":
|
| 33 |
+
return "cuda" if torch.cuda.is_available() else "cpu"
|
| 34 |
+
return config_device
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
DEVICE = get_device(config["device"])
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def evaluate(model, data_loader, device=DEVICE, save_dir="models/plots"):
|
| 41 |
+
"""
|
| 42 |
+
Evaluates a PyTorch model on a given DataLoader.
|
| 43 |
+
|
| 44 |
+
Args:
|
| 45 |
+
model: The PyTorch model to evaluate.
|
| 46 |
+
data_loader: The DataLoader providing the evaluation data.
|
| 47 |
+
device: The device to run evaluation on (e.g., 'cuda', 'cpu').
|
| 48 |
+
save_dir: Directory to save plots.
|
| 49 |
+
|
| 50 |
+
Returns:
|
| 51 |
+
avg_loss (float): The average loss over the dataset.
|
| 52 |
+
accuracy (float): The classification accuracy (0.0 to 1.0).
|
| 53 |
+
"""
|
| 54 |
+
model.to(device)
|
| 55 |
+
model.eval()
|
| 56 |
+
criterion = nn.CrossEntropyLoss()
|
| 57 |
+
|
| 58 |
+
total_loss = 0.0
|
| 59 |
+
correct = 0
|
| 60 |
+
total = 0
|
| 61 |
+
|
| 62 |
+
all_preds = []
|
| 63 |
+
all_labels = []
|
| 64 |
+
|
| 65 |
+
with torch.no_grad():
|
| 66 |
+
for images, labels in data_loader:
|
| 67 |
+
images, labels = images.to(device), labels.to(device)
|
| 68 |
+
|
| 69 |
+
outputs = model(images)
|
| 70 |
+
loss = criterion(outputs, labels)
|
| 71 |
+
|
| 72 |
+
total_loss += loss.item()
|
| 73 |
+
_, predicted = torch.max(outputs.data, 1)
|
| 74 |
+
total += labels.size(0)
|
| 75 |
+
correct += (predicted == labels).sum().item()
|
| 76 |
+
|
| 77 |
+
all_preds.extend(predicted.cpu().numpy())
|
| 78 |
+
all_labels.extend(labels.cpu().numpy())
|
| 79 |
+
|
| 80 |
+
avg_loss = total_loss / len(data_loader)
|
| 81 |
+
accuracy = correct / total
|
| 82 |
+
|
| 83 |
+
print("\nEvaluation Results:")
|
| 84 |
+
print(f"Average Loss: {avg_loss:.4f}")
|
| 85 |
+
print(f"Accuracy: {accuracy:.4f}")
|
| 86 |
+
|
| 87 |
+
# Classification Report
|
| 88 |
+
print("\nClassification Report:")
|
| 89 |
+
report = classification_report(
|
| 90 |
+
all_labels, all_preds, target_names=CLASSES, labels=range(len(CLASSES)), zero_division=0
|
| 91 |
+
)
|
| 92 |
+
print(report)
|
| 93 |
+
|
| 94 |
+
# Confusion Matrix
|
| 95 |
+
cm = confusion_matrix(all_labels, all_preds, labels=range(len(CLASSES)))
|
| 96 |
+
os.makedirs(save_dir, exist_ok=True)
|
| 97 |
+
|
| 98 |
+
fig, ax = plt.subplots(figsize=(10, 8))
|
| 99 |
+
disp = ConfusionMatrixDisplay(confusion_matrix=cm, display_labels=CLASSES)
|
| 100 |
+
disp.plot(cmap=plt.cm.Blues, ax=ax, xticks_rotation=45)
|
| 101 |
+
plt.title("Confusion Matrix")
|
| 102 |
+
plt.tight_layout()
|
| 103 |
+
plt.savefig(f"{save_dir}/confusion_matrix.png")
|
| 104 |
+
print(f"\nConfusion matrix saved to {save_dir}/confusion_matrix.png")
|
| 105 |
+
|
| 106 |
+
return avg_loss, accuracy
|
src/model.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch
|
| 2 |
+
import torch.nn as nn
|
| 3 |
+
from torchvision import models
|
| 4 |
+
from torchvision.models import ResNet18_Weights
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class SimpleCNN(nn.Module):
|
| 8 |
+
"""
|
| 9 |
+
A minimalist CNN model as a baseline.
|
| 10 |
+
Consists of two convolutional layers followed by a fully connected layer.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
def __init__(self, num_classes=6):
|
| 14 |
+
super(SimpleCNN, self).__init__()
|
| 15 |
+
# First Convolutional Block: Takes 3 channels (RGB) as input and
|
| 16 |
+
# outputs 16
|
| 17 |
+
self.conv1 = nn.Conv2d(3, 16, kernel_size=3, padding=1)
|
| 18 |
+
self.relu1 = nn.ReLU()
|
| 19 |
+
self.pool1 = nn.MaxPool2d(kernel_size=2, stride=2) # 224 -> 112
|
| 20 |
+
|
| 21 |
+
# Second Convolutional Block
|
| 22 |
+
self.conv2 = nn.Conv2d(16, 32, kernel_size=3, padding=1)
|
| 23 |
+
self.relu2 = nn.ReLU()
|
| 24 |
+
self.pool2 = nn.MaxPool2d(kernel_size=2, stride=2) # 112 -> 56
|
| 25 |
+
|
| 26 |
+
# Adaptive Pooling ensures the output is always 7x7, regardless of
|
| 27 |
+
# input size
|
| 28 |
+
self.adaptive_pool = nn.AdaptiveAvgPool2d((7, 7))
|
| 29 |
+
|
| 30 |
+
# Classification Layer
|
| 31 |
+
self.fc = nn.Linear(32 * 7 * 7, num_classes)
|
| 32 |
+
|
| 33 |
+
def forward(self, x):
|
| 34 |
+
"""
|
| 35 |
+
Defines the forward pass of the data through the network.
|
| 36 |
+
"""
|
| 37 |
+
x = self.pool1(self.relu1(self.conv1(x)))
|
| 38 |
+
x = self.pool2(self.relu2(self.conv2(x)))
|
| 39 |
+
x = self.adaptive_pool(x)
|
| 40 |
+
x = torch.flatten(x, 1) # Flatten for the linear layer
|
| 41 |
+
x = self.fc(x)
|
| 42 |
+
return x
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class DeepCNN(nn.Module):
|
| 46 |
+
"""
|
| 47 |
+
A deeper CNN model with Batch Normalization and Dropout for regularization.
|
| 48 |
+
Better suited for more complex image features.
|
| 49 |
+
"""
|
| 50 |
+
|
| 51 |
+
def __init__(self, num_classes=6):
|
| 52 |
+
super(DeepCNN, self).__init__()
|
| 53 |
+
|
| 54 |
+
# Block 1
|
| 55 |
+
self.layer1 = nn.Sequential(
|
| 56 |
+
nn.Conv2d(3, 32, kernel_size=3, padding=1),
|
| 57 |
+
nn.BatchNorm2d(32),
|
| 58 |
+
nn.ReLU(),
|
| 59 |
+
nn.MaxPool2d(kernel_size=2, stride=2), # 112
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
# Block 2
|
| 63 |
+
self.layer2 = nn.Sequential(
|
| 64 |
+
nn.Conv2d(32, 64, kernel_size=3, padding=1),
|
| 65 |
+
nn.BatchNorm2d(64),
|
| 66 |
+
nn.ReLU(),
|
| 67 |
+
nn.MaxPool2d(kernel_size=2, stride=2), # 56
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
# Block 3
|
| 71 |
+
self.layer3 = nn.Sequential(
|
| 72 |
+
nn.Conv2d(64, 128, kernel_size=3, padding=1),
|
| 73 |
+
nn.BatchNorm2d(128),
|
| 74 |
+
nn.ReLU(),
|
| 75 |
+
nn.MaxPool2d(kernel_size=2, stride=2), # 28
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
self.adaptive_pool = nn.AdaptiveAvgPool2d((7, 7))
|
| 79 |
+
|
| 80 |
+
# Classifier with Dropout to prevent overfitting
|
| 81 |
+
self.classifier = nn.Sequential(
|
| 82 |
+
nn.Linear(128 * 7 * 7, 512), nn.ReLU(), nn.Dropout(0.5), nn.Linear(512, num_classes)
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
def forward(self, x):
|
| 86 |
+
"""
|
| 87 |
+
Forward pass through the sequential layers.
|
| 88 |
+
"""
|
| 89 |
+
x = self.layer1(x)
|
| 90 |
+
x = self.layer2(x)
|
| 91 |
+
x = self.layer3(x)
|
| 92 |
+
x = self.adaptive_pool(x)
|
| 93 |
+
x = torch.flatten(x, 1)
|
| 94 |
+
x = self.classifier(x)
|
| 95 |
+
return x
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
class ResNet18Transfer(nn.Module):
|
| 99 |
+
"""
|
| 100 |
+
Transfer Learning model based on ResNet18.
|
| 101 |
+
Allows loading pretrained weights and freezing the backbone.
|
| 102 |
+
"""
|
| 103 |
+
|
| 104 |
+
def __init__(self, num_classes=6, pretrained=True, freeze_backbone=False):
|
| 105 |
+
super(ResNet18Transfer, self).__init__()
|
| 106 |
+
|
| 107 |
+
# Load the ResNet18 model
|
| 108 |
+
weights = ResNet18_Weights.DEFAULT if pretrained else None
|
| 109 |
+
self.backbone = models.resnet18(weights=weights)
|
| 110 |
+
|
| 111 |
+
# Freeze the backbone if requested
|
| 112 |
+
if freeze_backbone:
|
| 113 |
+
for param in self.backbone.parameters():
|
| 114 |
+
param.requires_grad = False
|
| 115 |
+
|
| 116 |
+
# Adjust the final fully connected layer (fc)
|
| 117 |
+
# ResNet18 fc has 512 input features by default
|
| 118 |
+
in_features = self.backbone.fc.in_features
|
| 119 |
+
self.backbone.fc = nn.Linear(in_features, num_classes)
|
| 120 |
+
|
| 121 |
+
def forward(self, x):
|
| 122 |
+
"""
|
| 123 |
+
Uses the ResNet backbone for feature extraction and classification.
|
| 124 |
+
"""
|
| 125 |
+
return self.backbone(x)
|
src/train.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
# Add project root to sys.path
|
| 6 |
+
sys.path.append(str(Path(__file__).parent.parent))
|
| 7 |
+
|
| 8 |
+
import matplotlib # noqa: E402
|
| 9 |
+
|
| 10 |
+
matplotlib.use("Agg")
|
| 11 |
+
import matplotlib.pyplot as plt # noqa: E402
|
| 12 |
+
import mlflow # noqa: E402
|
| 13 |
+
import numpy as np # noqa: E402
|
| 14 |
+
import torch # noqa: E402
|
| 15 |
+
import torch.nn as nn # noqa: E402
|
| 16 |
+
import yaml # noqa: E402
|
| 17 |
+
from torch.utils.data import DataLoader # noqa: E402
|
| 18 |
+
from torchvision import transforms # noqa: E402
|
| 19 |
+
from tqdm import tqdm # noqa: E402
|
| 20 |
+
|
| 21 |
+
from src.dataset import TrashDataset # noqa: E402
|
| 22 |
+
from src.model import DeepCNN, ResNet18Transfer, SimpleCNN # noqa: E402
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def load_config(config_path="config.yaml"):
|
| 26 |
+
with open(config_path, "r") as f:
|
| 27 |
+
return yaml.safe_load(f)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def get_device(config_device):
|
| 31 |
+
if config_device == "auto":
|
| 32 |
+
return "cuda" if torch.cuda.is_available() else "cpu"
|
| 33 |
+
return config_device
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class Trainer:
|
| 37 |
+
"""
|
| 38 |
+
Handles the training and validation lifecycle of a model with MLflow tracking.
|
| 39 |
+
"""
|
| 40 |
+
|
| 41 |
+
def __init__(self, model, train_loader, val_loader, config, model_name):
|
| 42 |
+
self.config = config
|
| 43 |
+
self.model_name = model_name
|
| 44 |
+
self.device = get_device(config["device"])
|
| 45 |
+
self.model = model.to(self.device)
|
| 46 |
+
self.train_loader = train_loader
|
| 47 |
+
self.val_loader = val_loader
|
| 48 |
+
self.epochs = config["epochs"]
|
| 49 |
+
self.patience = config.get("patience", 5)
|
| 50 |
+
|
| 51 |
+
# Handle class imbalance with weights
|
| 52 |
+
y_train = np.load("data/processed/y_train.npy")
|
| 53 |
+
class_counts = np.bincount(y_train)
|
| 54 |
+
weights = 1.0 / class_counts
|
| 55 |
+
weights = torch.FloatTensor(weights).to(self.device)
|
| 56 |
+
|
| 57 |
+
self.criterion = nn.CrossEntropyLoss(weight=weights)
|
| 58 |
+
self.optimizer = torch.optim.Adam(model.parameters(), lr=config["lr"])
|
| 59 |
+
self.scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(
|
| 60 |
+
self.optimizer, T_max=self.epochs
|
| 61 |
+
)
|
| 62 |
+
self.history = {"train_loss": [], "train_acc": [], "val_loss": [], "val_acc": []}
|
| 63 |
+
self.checkpoint_path = f"models/{model_name.lower()}_best.pth"
|
| 64 |
+
|
| 65 |
+
def train_epoch(self):
|
| 66 |
+
self.model.train()
|
| 67 |
+
running_loss = 0.0
|
| 68 |
+
correct = 0
|
| 69 |
+
total = 0
|
| 70 |
+
|
| 71 |
+
pbar = tqdm(self.train_loader, desc="Training", leave=False)
|
| 72 |
+
for images, labels in pbar:
|
| 73 |
+
images, labels = images.to(self.device), labels.to(self.device)
|
| 74 |
+
|
| 75 |
+
self.optimizer.zero_grad()
|
| 76 |
+
outputs = self.model(images)
|
| 77 |
+
loss = self.criterion(outputs, labels)
|
| 78 |
+
loss.backward()
|
| 79 |
+
self.optimizer.step()
|
| 80 |
+
|
| 81 |
+
running_loss += loss.item()
|
| 82 |
+
_, predicted = torch.max(outputs, 1)
|
| 83 |
+
total += labels.size(0)
|
| 84 |
+
correct += (predicted == labels).sum().item()
|
| 85 |
+
|
| 86 |
+
pbar.set_postfix({"loss": f"{loss.item():.4f}", "acc": f"{correct/total:.4f}"})
|
| 87 |
+
|
| 88 |
+
return running_loss / len(self.train_loader), correct / total
|
| 89 |
+
|
| 90 |
+
def validate(self):
|
| 91 |
+
self.model.eval()
|
| 92 |
+
running_loss = 0.0
|
| 93 |
+
correct = 0
|
| 94 |
+
total = 0
|
| 95 |
+
|
| 96 |
+
with torch.no_grad():
|
| 97 |
+
for images, labels in self.val_loader:
|
| 98 |
+
images, labels = images.to(self.device), labels.to(self.device)
|
| 99 |
+
outputs = self.model(images)
|
| 100 |
+
loss = self.criterion(outputs, labels)
|
| 101 |
+
|
| 102 |
+
running_loss += loss.item()
|
| 103 |
+
_, predicted = torch.max(outputs, 1)
|
| 104 |
+
total += labels.size(0)
|
| 105 |
+
correct += (predicted == labels).sum().item()
|
| 106 |
+
|
| 107 |
+
return running_loss / len(self.val_loader), correct / total
|
| 108 |
+
|
| 109 |
+
def plot_history(self):
|
| 110 |
+
save_path = f"models/plots/{self.model_name.lower()}_history.png"
|
| 111 |
+
os.makedirs(os.path.dirname(save_path), exist_ok=True)
|
| 112 |
+
epochs_range = range(1, len(self.history["train_loss"]) + 1)
|
| 113 |
+
|
| 114 |
+
plt.figure(figsize=(12, 5))
|
| 115 |
+
|
| 116 |
+
# Plot Loss
|
| 117 |
+
plt.subplot(1, 2, 1)
|
| 118 |
+
plt.plot(epochs_range, self.history["train_loss"], label="Train Loss")
|
| 119 |
+
plt.plot(epochs_range, self.history["val_loss"], label="Val Loss")
|
| 120 |
+
plt.title(f"{self.model_name} - Loss")
|
| 121 |
+
plt.xlabel("Epochs")
|
| 122 |
+
plt.ylabel("Loss")
|
| 123 |
+
plt.legend()
|
| 124 |
+
|
| 125 |
+
# Plot Accuracy
|
| 126 |
+
plt.subplot(1, 2, 2)
|
| 127 |
+
plt.plot(epochs_range, self.history["train_acc"], label="Train Acc")
|
| 128 |
+
plt.plot(epochs_range, self.history["val_acc"], label="Val Acc")
|
| 129 |
+
plt.title(f"{self.model_name} - Accuracy")
|
| 130 |
+
plt.xlabel("Epochs")
|
| 131 |
+
plt.ylabel("Accuracy")
|
| 132 |
+
plt.legend()
|
| 133 |
+
|
| 134 |
+
plt.tight_layout()
|
| 135 |
+
plt.savefig(save_path)
|
| 136 |
+
print(f"--> Training history plot saved to {save_path}")
|
| 137 |
+
mlflow.log_artifact(save_path)
|
| 138 |
+
|
| 139 |
+
def fit(self):
|
| 140 |
+
mlflow.set_experiment("Trash Classifier")
|
| 141 |
+
with mlflow.start_run(run_name=self.model_name):
|
| 142 |
+
mlflow.log_params(self.config)
|
| 143 |
+
mlflow.log_param("model_architecture", self.model_name)
|
| 144 |
+
|
| 145 |
+
print(f"\nStarting training for {self.model_name} on {self.device}...")
|
| 146 |
+
best_val_acc = 0.0
|
| 147 |
+
epochs_no_improve = 0
|
| 148 |
+
|
| 149 |
+
for epoch in range(self.epochs):
|
| 150 |
+
train_loss, train_acc = self.train_epoch()
|
| 151 |
+
val_loss, val_acc = self.validate()
|
| 152 |
+
self.scheduler.step()
|
| 153 |
+
|
| 154 |
+
self.history["train_loss"].append(train_loss)
|
| 155 |
+
self.history["train_acc"].append(train_acc)
|
| 156 |
+
self.history["val_loss"].append(val_loss)
|
| 157 |
+
self.history["val_acc"].append(val_acc)
|
| 158 |
+
|
| 159 |
+
mlflow.log_metric("train_loss", train_loss, step=epoch)
|
| 160 |
+
mlflow.log_metric("train_acc", train_acc, step=epoch)
|
| 161 |
+
mlflow.log_metric("val_loss", val_loss, step=epoch)
|
| 162 |
+
mlflow.log_metric("val_acc", val_acc, step=epoch)
|
| 163 |
+
mlflow.log_metric("lr", self.optimizer.param_groups[0]["lr"], step=epoch)
|
| 164 |
+
|
| 165 |
+
print(
|
| 166 |
+
f"Epoch [{epoch + 1}/{self.epochs}] "
|
| 167 |
+
f"Train Loss: {train_loss:.4f}, Acc: {train_acc:.4f} | "
|
| 168 |
+
f"Val Loss: {val_loss:.4f}, Acc: {val_acc:.4f} | "
|
| 169 |
+
f"LR: {self.optimizer.param_groups[0]['lr']:.6f}"
|
| 170 |
+
)
|
| 171 |
+
|
| 172 |
+
if val_acc > best_val_acc:
|
| 173 |
+
best_val_acc = val_acc
|
| 174 |
+
epochs_no_improve = 0
|
| 175 |
+
os.makedirs("models", exist_ok=True)
|
| 176 |
+
torch.save(self.model.state_dict(), self.checkpoint_path)
|
| 177 |
+
print(f"--> Saved best model for {self.model_name} with Val Acc: {val_acc:.4f}")
|
| 178 |
+
mlflow.log_artifact(self.checkpoint_path)
|
| 179 |
+
else:
|
| 180 |
+
epochs_no_improve += 1
|
| 181 |
+
if epochs_no_improve >= self.patience:
|
| 182 |
+
print(f"Early stopping triggered after {epoch + 1} epochs.")
|
| 183 |
+
break
|
| 184 |
+
|
| 185 |
+
self.plot_history()
|
| 186 |
+
return self.history
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
if __name__ == "__main__":
|
| 190 |
+
config = load_config()
|
| 191 |
+
|
| 192 |
+
train_transform = transforms.Compose(
|
| 193 |
+
[
|
| 194 |
+
transforms.ToPILImage(),
|
| 195 |
+
transforms.RandomResizedCrop(224, scale=(0.8, 1.0)),
|
| 196 |
+
transforms.RandomHorizontalFlip(),
|
| 197 |
+
transforms.RandomRotation(15),
|
| 198 |
+
transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2),
|
| 199 |
+
transforms.ToTensor(),
|
| 200 |
+
transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
|
| 201 |
+
]
|
| 202 |
+
)
|
| 203 |
+
|
| 204 |
+
val_transform = transforms.Compose(
|
| 205 |
+
[
|
| 206 |
+
transforms.ToPILImage(),
|
| 207 |
+
transforms.ToTensor(),
|
| 208 |
+
transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
|
| 209 |
+
]
|
| 210 |
+
)
|
| 211 |
+
|
| 212 |
+
processed_dir = Path("data/processed")
|
| 213 |
+
if not (processed_dir / "X_train.npy").exists():
|
| 214 |
+
print("Error: Processed data not found. Please run src/dataset.py first.")
|
| 215 |
+
else:
|
| 216 |
+
train_ds = TrashDataset(
|
| 217 |
+
processed_dir / "X_train.npy", processed_dir / "y_train.npy", transform=train_transform
|
| 218 |
+
)
|
| 219 |
+
val_ds = TrashDataset(
|
| 220 |
+
processed_dir / "X_val.npy", processed_dir / "y_val.npy", transform=val_transform
|
| 221 |
+
)
|
| 222 |
+
|
| 223 |
+
train_loader = DataLoader(train_ds, batch_size=config["batch_size"], shuffle=True)
|
| 224 |
+
val_loader = DataLoader(val_ds, batch_size=config["batch_size"], shuffle=False)
|
| 225 |
+
|
| 226 |
+
num_classes = len(config["classes"])
|
| 227 |
+
models_to_train = {
|
| 228 |
+
"SimpleCNN": SimpleCNN(num_classes=num_classes),
|
| 229 |
+
"DeepCNN": DeepCNN(num_classes=num_classes),
|
| 230 |
+
"ResNet18": ResNet18Transfer(num_classes=num_classes, pretrained=True),
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
for name, model in models_to_train.items():
|
| 234 |
+
trainer = Trainer(model, train_loader, val_loader, config, name)
|
| 235 |
+
trainer.fit()
|
| 236 |
+
|
| 237 |
+
print("\nAll models trained. Starting comparison...")
|
| 238 |
+
from src.comparison import run_comparison
|
| 239 |
+
|
| 240 |
+
run_comparison()
|