Spaces:
Sleeping
Sleeping
Upload 5 files
Browse files- Dockerfile +23 -0
- checkpoints/best_age_model.pth +3 -0
- main.py +170 -0
- requirements.txt +7 -0
- server.py +89 -0
Dockerfile
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Use a lightweight Python image
|
| 2 |
+
FROM python:3.9-slim
|
| 3 |
+
|
| 4 |
+
WORKDIR /code
|
| 5 |
+
|
| 6 |
+
# 1. Install CPU-only PyTorch (Critical for Free Tier to avoid 3GB bloat)
|
| 7 |
+
RUN pip install --no-cache-dir torch torchvision --index-url https://download.pytorch.org/whl/cpu
|
| 8 |
+
|
| 9 |
+
# 2. Copy requirements and install dependencies
|
| 10 |
+
COPY ./requirements.txt /code/requirements.txt
|
| 11 |
+
RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt
|
| 12 |
+
|
| 13 |
+
# 3. Create checkpoints directory and set permissions
|
| 14 |
+
# We allow any user to write here to avoid permission errors on some clouds
|
| 15 |
+
RUN mkdir -p /code/checkpoints && chmod -R 777 /code/checkpoints
|
| 16 |
+
|
| 17 |
+
# 4. Copy your code and the TRAINED MODEL
|
| 18 |
+
COPY server.py /code/server.py
|
| 19 |
+
COPY checkpoints/ /code/checkpoints/
|
| 20 |
+
|
| 21 |
+
# 5. Start the Server
|
| 22 |
+
# Port 7860 is the default for Hugging Face Spaces
|
| 23 |
+
CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "7860"]
|
checkpoints/best_age_model.pth
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:6123e7653e7de692f0c0509b1429c5978709fc3ab0595059978e6c0d17c2cdc5
|
| 3 |
+
size 45050069
|
main.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import glob
|
| 3 |
+
import torch
|
| 4 |
+
import torch.nn as nn
|
| 5 |
+
import torch.optim as optim
|
| 6 |
+
from torch.utils.data import Dataset, DataLoader, random_split
|
| 7 |
+
from torchvision import models, transforms as T
|
| 8 |
+
from PIL import Image
|
| 9 |
+
from tqdm import tqdm # Progress bar
|
| 10 |
+
|
| 11 |
+
# =================CONFIGURATION=================
|
| 12 |
+
# Update this path based on your screenshot
|
| 13 |
+
DATA_DIR = "dataset/UTKFace"
|
| 14 |
+
CHECKPOINT_DIR = "checkpoints"
|
| 15 |
+
BATCH_SIZE = 64
|
| 16 |
+
EPOCHS = 10
|
| 17 |
+
LEARNING_RATE = 1e-4
|
| 18 |
+
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 19 |
+
# ===============================================
|
| 20 |
+
|
| 21 |
+
# We must use a tiny class for Data because PyTorch requires it
|
| 22 |
+
# But we keep it minimal (just loading logic)
|
| 23 |
+
class SimpleFaceDataset(Dataset):
|
| 24 |
+
def __init__(self, root_dir, transform=None):
|
| 25 |
+
self.transform = transform
|
| 26 |
+
# Find all files ending in .jpg (some in your screenshot have .chip.jpg)
|
| 27 |
+
self.files = glob.glob(os.path.join(root_dir, "*.jpg*"))
|
| 28 |
+
print(f"Dataset: Found {len(self.files)} images.")
|
| 29 |
+
|
| 30 |
+
def __len__(self):
|
| 31 |
+
return len(self.files)
|
| 32 |
+
|
| 33 |
+
def __getitem__(self, idx):
|
| 34 |
+
filepath = self.files[idx]
|
| 35 |
+
filename = os.path.basename(filepath)
|
| 36 |
+
|
| 37 |
+
# 1. Parse Age from Filename
|
| 38 |
+
# Example: "26_0_1_2017.jpg" -> "26" is the first part
|
| 39 |
+
try:
|
| 40 |
+
age = int(filename.split('_')[0])
|
| 41 |
+
except:
|
| 42 |
+
age = 25 # Default safe fallback
|
| 43 |
+
|
| 44 |
+
# 2. Load Image
|
| 45 |
+
image = Image.open(filepath).convert('RGB')
|
| 46 |
+
|
| 47 |
+
if self.transform:
|
| 48 |
+
image = self.transform(image)
|
| 49 |
+
|
| 50 |
+
# Return image and age as a Float (needed for regression)
|
| 51 |
+
return image, torch.tensor(age, dtype=torch.float32)
|
| 52 |
+
|
| 53 |
+
def get_model():
|
| 54 |
+
"""Loads ResNet18 and changes output to 1 number (Age)."""
|
| 55 |
+
print("Loading ResNet18...")
|
| 56 |
+
model = models.resnet18(weights="DEFAULT")
|
| 57 |
+
|
| 58 |
+
# Change the final layer
|
| 59 |
+
# Old: Output 1000 classes (ImageNet)
|
| 60 |
+
# New: Output 1 number (Age)
|
| 61 |
+
num_features = model.fc.in_features
|
| 62 |
+
model.fc = nn.Sequential(
|
| 63 |
+
nn.Linear(num_features, 128),
|
| 64 |
+
nn.ReLU(),
|
| 65 |
+
nn.Dropout(0.2), # Helps prevent memorization
|
| 66 |
+
nn.Linear(128, 1)
|
| 67 |
+
)
|
| 68 |
+
return model.to(DEVICE)
|
| 69 |
+
|
| 70 |
+
def train_one_epoch(model, loader, criterion, optimizer):
|
| 71 |
+
model.train()
|
| 72 |
+
running_loss = 0.0
|
| 73 |
+
total_error = 0.0 # To track how many years we are off by
|
| 74 |
+
count = 0
|
| 75 |
+
|
| 76 |
+
loop = tqdm(loader, desc="Training", leave=False)
|
| 77 |
+
for images, ages in loop:
|
| 78 |
+
images, ages = images.to(DEVICE), ages.to(DEVICE)
|
| 79 |
+
|
| 80 |
+
# Forward
|
| 81 |
+
predictions = model(images).squeeze() # remove extra dim
|
| 82 |
+
loss = criterion(predictions, ages)
|
| 83 |
+
|
| 84 |
+
# Backward
|
| 85 |
+
optimizer.zero_grad()
|
| 86 |
+
loss.backward()
|
| 87 |
+
optimizer.step()
|
| 88 |
+
|
| 89 |
+
# Stats
|
| 90 |
+
running_loss += loss.item()
|
| 91 |
+
# Calculate Absolute Error (e.g., predicted 25, real 30 -> error 5)
|
| 92 |
+
total_error += torch.abs(predictions - ages).sum().item()
|
| 93 |
+
count += ages.size(0)
|
| 94 |
+
|
| 95 |
+
loop.set_description(f"Loss: {loss.item():.2f}")
|
| 96 |
+
|
| 97 |
+
avg_loss = running_loss / len(loader)
|
| 98 |
+
avg_mae = total_error / count # Mean Absolute Error
|
| 99 |
+
return avg_loss, avg_mae
|
| 100 |
+
|
| 101 |
+
def evaluate(model, loader, criterion):
|
| 102 |
+
model.eval()
|
| 103 |
+
running_loss = 0.0
|
| 104 |
+
total_error = 0.0
|
| 105 |
+
count = 0
|
| 106 |
+
|
| 107 |
+
with torch.no_grad():
|
| 108 |
+
for images, ages in loader:
|
| 109 |
+
images, ages = images.to(DEVICE), ages.to(DEVICE)
|
| 110 |
+
|
| 111 |
+
predictions = model(images).squeeze()
|
| 112 |
+
loss = criterion(predictions, ages)
|
| 113 |
+
|
| 114 |
+
running_loss += loss.item()
|
| 115 |
+
total_error += torch.abs(predictions - ages).sum().item()
|
| 116 |
+
count += ages.size(0)
|
| 117 |
+
|
| 118 |
+
avg_loss = running_loss / len(loader)
|
| 119 |
+
avg_mae = total_error / count
|
| 120 |
+
return avg_loss, avg_mae
|
| 121 |
+
|
| 122 |
+
# ================= MAIN SCRIPT =================
|
| 123 |
+
if __name__ == "__main__":
|
| 124 |
+
# 1. Setup Transforms (Resize to 200x200 standard)
|
| 125 |
+
transform = T.Compose([
|
| 126 |
+
T.Resize((200, 200)),
|
| 127 |
+
T.ToTensor(),
|
| 128 |
+
T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
|
| 129 |
+
])
|
| 130 |
+
|
| 131 |
+
# 2. Setup Data
|
| 132 |
+
full_dataset = SimpleFaceDataset(DATA_DIR, transform=transform)
|
| 133 |
+
|
| 134 |
+
# 80/20 Split
|
| 135 |
+
train_size = int(0.8 * len(full_dataset))
|
| 136 |
+
val_size = len(full_dataset) - train_size
|
| 137 |
+
train_ds, val_ds = random_split(full_dataset, [train_size, val_size])
|
| 138 |
+
|
| 139 |
+
train_loader = DataLoader(train_ds, batch_size=BATCH_SIZE, shuffle=True, num_workers=4)
|
| 140 |
+
val_loader = DataLoader(val_ds, batch_size=BATCH_SIZE, num_workers=4)
|
| 141 |
+
|
| 142 |
+
# 3. Setup Model, Loss, Optimizer
|
| 143 |
+
model = get_model()
|
| 144 |
+
# MSELoss is standard for predicting numbers (Regression)
|
| 145 |
+
criterion = nn.MSELoss()
|
| 146 |
+
optimizer = optim.Adam(model.parameters(), lr=LEARNING_RATE)
|
| 147 |
+
|
| 148 |
+
os.makedirs(CHECKPOINT_DIR, exist_ok=True)
|
| 149 |
+
|
| 150 |
+
print(f"Starting training on {DEVICE}...")
|
| 151 |
+
|
| 152 |
+
best_mae = float('inf') # Lower is better
|
| 153 |
+
|
| 154 |
+
for epoch in range(EPOCHS):
|
| 155 |
+
print(f"\nEpoch {epoch+1}/{EPOCHS}")
|
| 156 |
+
|
| 157 |
+
train_loss, train_mae = train_one_epoch(model, train_loader, criterion, optimizer)
|
| 158 |
+
print(f"Train Loss: {train_loss:.2f} | Avg Error: {train_mae:.1f} years")
|
| 159 |
+
|
| 160 |
+
val_loss, val_mae = evaluate(model, val_loader, criterion)
|
| 161 |
+
print(f"Val Loss: {val_loss:.2f} | Avg Error: {val_mae:.1f} years")
|
| 162 |
+
|
| 163 |
+
# Save Best
|
| 164 |
+
if val_mae < best_mae:
|
| 165 |
+
best_mae = val_mae
|
| 166 |
+
save_path = os.path.join(CHECKPOINT_DIR, "best_age_model.pth")
|
| 167 |
+
torch.save(model.state_dict(), save_path)
|
| 168 |
+
print(f"🔥 Model saved! (Off by only {val_mae:.1f} years on avg)")
|
| 169 |
+
|
| 170 |
+
print("Done!")
|
requirements.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi
|
| 2 |
+
uvicorn
|
| 3 |
+
python-multipart
|
| 4 |
+
torch
|
| 5 |
+
torchvision
|
| 6 |
+
pillow
|
| 7 |
+
tqdm
|
server.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import io
|
| 2 |
+
import os
|
| 3 |
+
import glob
|
| 4 |
+
import torch
|
| 5 |
+
import torch.nn as nn
|
| 6 |
+
from torchvision import models, transforms as T
|
| 7 |
+
from PIL import Image
|
| 8 |
+
from fastapi import FastAPI, UploadFile, File, HTTPException
|
| 9 |
+
import uvicorn
|
| 10 |
+
|
| 11 |
+
app = FastAPI()
|
| 12 |
+
|
| 13 |
+
# =================CONFIGURATION=================
|
| 14 |
+
CHECKPOINT_PATH = "checkpoints/best_age_model.pth"
|
| 15 |
+
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 16 |
+
# ===============================================
|
| 17 |
+
|
| 18 |
+
# 1. Define the Model Architecture (Must match training EXACTLY)
|
| 19 |
+
def get_model():
|
| 20 |
+
# Load standard ResNet18 structure
|
| 21 |
+
model = models.resnet18(weights=None)
|
| 22 |
+
|
| 23 |
+
# Reconstruct the final layer exactly as we defined in training
|
| 24 |
+
num_features = model.fc.in_features
|
| 25 |
+
model.fc = nn.Sequential(
|
| 26 |
+
nn.Linear(num_features, 128),
|
| 27 |
+
nn.ReLU(),
|
| 28 |
+
nn.Dropout(0.2),
|
| 29 |
+
nn.Linear(128, 1)
|
| 30 |
+
)
|
| 31 |
+
return model
|
| 32 |
+
|
| 33 |
+
# 2. Load Weights
|
| 34 |
+
print(f"Initializing model on {DEVICE}...")
|
| 35 |
+
model = get_model()
|
| 36 |
+
|
| 37 |
+
if os.path.exists(CHECKPOINT_PATH):
|
| 38 |
+
print(f"Loading weights from {CHECKPOINT_PATH}...")
|
| 39 |
+
# Load the state dictionary
|
| 40 |
+
state_dict = torch.load(CHECKPOINT_PATH, map_location=DEVICE)
|
| 41 |
+
model.load_state_dict(state_dict)
|
| 42 |
+
model.to(DEVICE)
|
| 43 |
+
model.eval() # Important: Turn off Dropout for inference
|
| 44 |
+
print("✅ Model loaded successfully!")
|
| 45 |
+
else:
|
| 46 |
+
print(f"⚠️ Warning: Checkpoint not found at {CHECKPOINT_PATH}")
|
| 47 |
+
print("Inference will use random weights (garbage output).")
|
| 48 |
+
|
| 49 |
+
# 3. Define Transforms (Must match training)
|
| 50 |
+
transform = T.Compose([
|
| 51 |
+
T.Resize((200, 200)),
|
| 52 |
+
T.ToTensor(),
|
| 53 |
+
T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
|
| 54 |
+
])
|
| 55 |
+
|
| 56 |
+
@app.get("/")
|
| 57 |
+
def home():
|
| 58 |
+
return {"status": "Age Predictor API is running"}
|
| 59 |
+
|
| 60 |
+
@app.post("/predict")
|
| 61 |
+
async def predict_age(file: UploadFile = File(...)):
|
| 62 |
+
"""
|
| 63 |
+
Accepts an image file and returns predicted age.
|
| 64 |
+
"""
|
| 65 |
+
try:
|
| 66 |
+
# 1. Read and Process Image
|
| 67 |
+
content = await file.read()
|
| 68 |
+
image = Image.open(io.BytesIO(content)).convert("RGB")
|
| 69 |
+
|
| 70 |
+
# 2. Transform
|
| 71 |
+
input_tensor = transform(image).unsqueeze(0).to(DEVICE)
|
| 72 |
+
|
| 73 |
+
# 3. Inference
|
| 74 |
+
with torch.no_grad():
|
| 75 |
+
prediction = model(input_tensor).squeeze()
|
| 76 |
+
predicted_age = prediction.item()
|
| 77 |
+
|
| 78 |
+
# 4. Return Result
|
| 79 |
+
return {
|
| 80 |
+
"filename": file.filename,
|
| 81 |
+
"predicted_age": round(predicted_age, 1) # Round to 1 decimal
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
except Exception as e:
|
| 85 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 86 |
+
|
| 87 |
+
if __name__ == "__main__":
|
| 88 |
+
# Host 0.0.0.0 is needed for cloud environments/Docker
|
| 89 |
+
uvicorn.run(app, host="0.0.0.0", port=8000)
|