Commit
·
52109c6
1
Parent(s):
33c6b5f
upload app and supporting files
Browse files- app.py +71 -0
- data/models/expression_predictor_cnn.pth +3 -0
- src/streamlit_app.py +0 -40
- training_loss_plot.png +0 -0
- utils/model_loader.py +28 -0
- utils/train.py +121 -0
app.py
ADDED
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# app.py
|
2 |
+
import streamlit as st
|
3 |
+
from streamlit_webrtc import webrtc_streamer, VideoProcessorBase
|
4 |
+
import av
|
5 |
+
import cv2
|
6 |
+
import torch
|
7 |
+
import numpy as np
|
8 |
+
from torchvision import transforms
|
9 |
+
from utils.model_loader import load_model
|
10 |
+
|
11 |
+
|
12 |
+
st.title("Facial Expression Recognition")
|
13 |
+
|
14 |
+
# Load model
|
15 |
+
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
16 |
+
MODEL_PATH = "data/models/expression_predictor_cnn.pth"
|
17 |
+
CLASSES = ['Angry', 'Disgust', 'Scared', 'Happy', 'Neutral', 'Sad', 'Surprised']
|
18 |
+
model = load_model(MODEL_PATH, DEVICE)
|
19 |
+
|
20 |
+
# Face detection
|
21 |
+
face_cascade = cv2.CascadeClassifier(cv2.data.haarcascades + 'haarcascade_frontalface_default.xml')
|
22 |
+
|
23 |
+
# Transform for inference
|
24 |
+
transform = transforms.Compose([
|
25 |
+
transforms.ToPILImage(),
|
26 |
+
transforms.Grayscale(),
|
27 |
+
transforms.Resize((48, 48)),
|
28 |
+
transforms.ToTensor(),
|
29 |
+
transforms.Normalize((0.5,), (0.5,))
|
30 |
+
])
|
31 |
+
|
32 |
+
# Video processor
|
33 |
+
livestatus = st.empty()
|
34 |
+
class VideoProcessor(VideoProcessorBase):
|
35 |
+
def recv(self, frame):
|
36 |
+
global global_face_data
|
37 |
+
img = frame.to_ndarray(format="bgr24")
|
38 |
+
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
39 |
+
faces = face_cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=5)
|
40 |
+
|
41 |
+
face_data = []
|
42 |
+
|
43 |
+
for i, (x, y, w, h) in enumerate(faces):
|
44 |
+
face_crop = gray[y:y+h, x:x+w]
|
45 |
+
face_tensor = transform(face_crop).unsqueeze(0).to(DEVICE)
|
46 |
+
|
47 |
+
with torch.no_grad():
|
48 |
+
outputs = model(face_tensor)
|
49 |
+
probs = torch.nn.functional.softmax(outputs, dim=1)[0].cpu().numpy()
|
50 |
+
top_idx = np.argmax(probs)
|
51 |
+
label = CLASSES[top_idx]
|
52 |
+
|
53 |
+
# Draw face + label on video
|
54 |
+
face_id = f"Face {i+1}"
|
55 |
+
cv2.rectangle(img, (x, y), (x+w, y+h), (0, 255, 0), 1)
|
56 |
+
cv2.putText(img, f"{face_id}: {label}", (x, y - 10),
|
57 |
+
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1)
|
58 |
+
|
59 |
+
return av.VideoFrame.from_ndarray(img, format="bgr24")
|
60 |
+
|
61 |
+
ctx = webrtc_streamer(
|
62 |
+
key="emotion-detect",
|
63 |
+
video_processor_factory=VideoProcessor,
|
64 |
+
media_stream_constraints={"video": True, "audio": False},
|
65 |
+
async_processing=True,
|
66 |
+
)
|
67 |
+
|
68 |
+
if ctx.state.playing:
|
69 |
+
livestatus.success("🟢 Live")
|
70 |
+
else:
|
71 |
+
livestatus.error("🔴 Offline")
|
data/models/expression_predictor_cnn.pth
ADDED
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
1 |
+
version https://git-lfs.github.com/spec/v1
|
2 |
+
oid sha256:dbe74499e1659461a187bd820854520513fb94b2e9c094da7c2adf2915038d32
|
3 |
+
size 1576685
|
src/streamlit_app.py
DELETED
@@ -1,40 +0,0 @@
|
|
1 |
-
import altair as alt
|
2 |
-
import numpy as np
|
3 |
-
import pandas as pd
|
4 |
-
import streamlit as st
|
5 |
-
|
6 |
-
"""
|
7 |
-
# Welcome to Streamlit!
|
8 |
-
|
9 |
-
Edit `/streamlit_app.py` to customize this app to your heart's desire :heart:.
|
10 |
-
If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
|
11 |
-
forums](https://discuss.streamlit.io).
|
12 |
-
|
13 |
-
In the meantime, below is an example of what you can do with just a few lines of code:
|
14 |
-
"""
|
15 |
-
|
16 |
-
num_points = st.slider("Number of points in spiral", 1, 10000, 1100)
|
17 |
-
num_turns = st.slider("Number of turns in spiral", 1, 300, 31)
|
18 |
-
|
19 |
-
indices = np.linspace(0, 1, num_points)
|
20 |
-
theta = 2 * np.pi * num_turns * indices
|
21 |
-
radius = indices
|
22 |
-
|
23 |
-
x = radius * np.cos(theta)
|
24 |
-
y = radius * np.sin(theta)
|
25 |
-
|
26 |
-
df = pd.DataFrame({
|
27 |
-
"x": x,
|
28 |
-
"y": y,
|
29 |
-
"idx": indices,
|
30 |
-
"rand": np.random.randn(num_points),
|
31 |
-
})
|
32 |
-
|
33 |
-
st.altair_chart(alt.Chart(df, height=700, width=700)
|
34 |
-
.mark_point(filled=True)
|
35 |
-
.encode(
|
36 |
-
x=alt.X("x", axis=None),
|
37 |
-
y=alt.Y("y", axis=None),
|
38 |
-
color=alt.Color("idx", legend=None, scale=alt.Scale()),
|
39 |
-
size=alt.Size("rand", legend=None, scale=alt.Scale(range=[1, 150])),
|
40 |
-
))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
training_loss_plot.png
ADDED
![]() |
utils/model_loader.py
ADDED
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import torch
|
2 |
+
import torch.nn as nn
|
3 |
+
|
4 |
+
class ExpressionCNN(nn.Module):
|
5 |
+
def __init__(self, num_classes=7):
|
6 |
+
super(ExpressionCNN, self).__init__()
|
7 |
+
self.conv = nn.Sequential(
|
8 |
+
nn.Conv2d(1, 32, 3, padding=1), nn.ReLU(), nn.BatchNorm2d(32), nn.MaxPool2d(2),
|
9 |
+
nn.Conv2d(32, 64, 3, padding=1), nn.ReLU(), nn.BatchNorm2d(64), nn.MaxPool2d(2),
|
10 |
+
nn.Conv2d(64, 128, 3, padding=1), nn.ReLU(), nn.BatchNorm2d(128), nn.MaxPool2d(2),
|
11 |
+
nn.Conv2d(128, 256, 3, padding=1), nn.ReLU(), nn.BatchNorm2d(256), nn.AdaptiveAvgPool2d((1, 1))
|
12 |
+
)
|
13 |
+
self.fc = nn.Sequential(
|
14 |
+
nn.Flatten(),
|
15 |
+
nn.Linear(256, num_classes)
|
16 |
+
)
|
17 |
+
|
18 |
+
def forward(self, x):
|
19 |
+
x = self.conv(x)
|
20 |
+
x = self.fc(x)
|
21 |
+
return x
|
22 |
+
|
23 |
+
def load_model(model_path, device):
|
24 |
+
model = ExpressionCNN()
|
25 |
+
model.load_state_dict(torch.load(model_path, map_location=device))
|
26 |
+
model.to(device)
|
27 |
+
model.eval()
|
28 |
+
return model
|
utils/train.py
ADDED
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import torch
|
2 |
+
import torch.nn as nn
|
3 |
+
import torch.optim as optim
|
4 |
+
from torchvision import datasets, transforms
|
5 |
+
from torch.utils.data import DataLoader
|
6 |
+
import matplotlib.pyplot as plt
|
7 |
+
from PIL import ImageFile
|
8 |
+
|
9 |
+
# Configuration
|
10 |
+
BATCH_SIZE = 64
|
11 |
+
EPOCHS = 10
|
12 |
+
IMG_SIZE = 48
|
13 |
+
MODEL_PATH = "data/models/expression_predictor_cnn.pth"
|
14 |
+
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
15 |
+
print(f"Using device: {DEVICE}")
|
16 |
+
|
17 |
+
ImageFile.LOAD_TRUNCATED_IMAGES = True
|
18 |
+
|
19 |
+
# Data transforms
|
20 |
+
transform = transforms.Compose([
|
21 |
+
transforms.Grayscale(),
|
22 |
+
transforms.Resize((48, 48)),
|
23 |
+
transforms.RandomHorizontalFlip(),
|
24 |
+
transforms.RandomRotation(10),
|
25 |
+
transforms.RandomAffine(degrees=0, translate=(0.1, 0.1)),
|
26 |
+
transforms.ToTensor(),
|
27 |
+
transforms.Normalize((0.5,), (0.5,))
|
28 |
+
])
|
29 |
+
|
30 |
+
# Datasets and loaders
|
31 |
+
train_dataset = datasets.ImageFolder("data/train", transform=transform)
|
32 |
+
val_dataset = datasets.ImageFolder("data/validation", transform=transform)
|
33 |
+
|
34 |
+
train_loader = DataLoader(train_dataset, batch_size=BATCH_SIZE, shuffle=True, num_workers=0)
|
35 |
+
val_loader = DataLoader(val_dataset, batch_size=BATCH_SIZE, num_workers=0)
|
36 |
+
|
37 |
+
# Class names
|
38 |
+
CLASSES = train_dataset.classes
|
39 |
+
NUM_CLASSES = len(CLASSES)
|
40 |
+
print(f"Classes: {CLASSES}")
|
41 |
+
|
42 |
+
# CNN Model
|
43 |
+
class ExpressionCNN(nn.Module):
|
44 |
+
def __init__(self):
|
45 |
+
super(ExpressionCNN, self).__init__()
|
46 |
+
self.conv = nn.Sequential(
|
47 |
+
nn.Conv2d(1, 32, 3, padding=1), nn.ReLU(), nn.BatchNorm2d(32), nn.MaxPool2d(2),
|
48 |
+
nn.Conv2d(32, 64, 3, padding=1), nn.ReLU(), nn.BatchNorm2d(64), nn.MaxPool2d(2),
|
49 |
+
nn.Conv2d(64, 128, 3, padding=1), nn.ReLU(), nn.BatchNorm2d(128), nn.MaxPool2d(2),
|
50 |
+
nn.Conv2d(128, 256, 3, padding=1), nn.ReLU(), nn.BatchNorm2d(256), nn.AdaptiveAvgPool2d((1, 1))
|
51 |
+
)
|
52 |
+
self.fc = nn.Sequential(
|
53 |
+
nn.Flatten(),
|
54 |
+
nn.Linear(256, NUM_CLASSES)
|
55 |
+
)
|
56 |
+
|
57 |
+
def forward(self, x):
|
58 |
+
x = self.conv(x)
|
59 |
+
x = self.fc(x)
|
60 |
+
return x
|
61 |
+
|
62 |
+
model = ExpressionCNN().to(DEVICE)
|
63 |
+
criterion = nn.CrossEntropyLoss()
|
64 |
+
optimizer = optim.Adam(model.parameters(), lr=0.001)
|
65 |
+
scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=5, gamma=0.5)
|
66 |
+
|
67 |
+
# Training loop
|
68 |
+
train_loss_log = []
|
69 |
+
val_loss_log = []
|
70 |
+
|
71 |
+
for epoch in range(EPOCHS):
|
72 |
+
print(f"\nStarting Epoch {epoch+1}/{EPOCHS}")
|
73 |
+
model.train()
|
74 |
+
running_loss = 0.0
|
75 |
+
for images, labels in train_loader:
|
76 |
+
images, labels = images.to(DEVICE), labels.to(DEVICE)
|
77 |
+
optimizer.zero_grad()
|
78 |
+
outputs = model(images)
|
79 |
+
loss = criterion(outputs, labels)
|
80 |
+
loss.backward()
|
81 |
+
optimizer.step()
|
82 |
+
running_loss += loss.item()
|
83 |
+
scheduler.step()
|
84 |
+
|
85 |
+
train_loss = running_loss / len(train_loader)
|
86 |
+
train_loss_log.append(train_loss)
|
87 |
+
|
88 |
+
# Validation
|
89 |
+
model.eval()
|
90 |
+
val_loss = 0.0
|
91 |
+
correct = 0
|
92 |
+
total = 0
|
93 |
+
with torch.no_grad():
|
94 |
+
for images, labels in val_loader:
|
95 |
+
images, labels = images.to(DEVICE), labels.to(DEVICE)
|
96 |
+
outputs = model(images)
|
97 |
+
loss = criterion(outputs, labels)
|
98 |
+
val_loss += loss.item()
|
99 |
+
_, predicted = torch.max(outputs, 1)
|
100 |
+
correct += (predicted == labels).sum().item()
|
101 |
+
total += labels.size(0)
|
102 |
+
|
103 |
+
val_loss /= len(val_loader)
|
104 |
+
val_loss_log.append(val_loss)
|
105 |
+
accuracy = correct / total * 100
|
106 |
+
|
107 |
+
print(f"[{epoch+1}/{EPOCHS}] Train Loss: {train_loss:.4f} | Val Loss: {val_loss:.4f} | Val Acc: {accuracy:.2f}%")
|
108 |
+
|
109 |
+
# Save model
|
110 |
+
torch.save(model.state_dict(), MODEL_PATH)
|
111 |
+
print(f"✅ Model saved to {MODEL_PATH}")
|
112 |
+
|
113 |
+
# Plot loss
|
114 |
+
plt.plot(train_loss_log, label="Train")
|
115 |
+
plt.plot(val_loss_log, label="Validation")
|
116 |
+
plt.title("Loss Curve")
|
117 |
+
plt.xlabel("Epoch")
|
118 |
+
plt.ylabel("Loss")
|
119 |
+
plt.legend()
|
120 |
+
plt.grid()
|
121 |
+
plt.savefig("training_loss_plot.png")
|