EduReflect / src /streamlit_app.py
achmadichzan's picture
Update src/streamlit_app.py
6f4ff29 verified
Raw
History Blame Contribute Delete
17.7 kB
import os
# Memaksa OpenMP dan ONNX Runtime untuk menggunakan lebih banyak thread CPU
os.environ["OMP_NUM_THREADS"] = "4"
os.environ["MKL_NUM_THREADS"] = "4"
os.environ["NUMEXPR_NUM_THREADS"] = "4"
import streamlit as st
import cv2
import tempfile
import pandas as pd
import plotly.express as px
from ultralytics import YOLO
# --- PERUBAHAN LAYOUT DI SINI ---
# Mengubah layout="wide" menjadi layout="centered" agar tampilan lebih fokus ke tengah
st.set_page_config(page_title="EduReflect", page_icon="🎓", layout="centered")
st.markdown(
"""
<style>
/* Mengatur lebar ideal agar tidak terlalu sempit dan tidak terlalu lebar */
.block-container {
max-width: 950px;
padding-top: 2rem;
}
</style>
""",
unsafe_allow_html=True
)
st.title("EduReflect: Analisis Emosi Kelas 🎓")
st.markdown("Unggah rekaman video suasana kelas untuk menganalisis metrik emosi siswa menggunakan AI.")
# --- PENINGKATAN 4: CACHING MODEL ---
# Model hanya di-load 1 kali saat server nyala, menghemat RAM dan Waktu
@st.cache_resource
def load_models():
# Pastikan file model ada di dalam folder 'src' di repository kamu
face = YOLO('src/yolov11n-face.onnx', task='detect')
emotion = YOLO('src/best_int8_openvino_model', task='detect')
return face, emotion
# Load model di awal
face_model, emotion_model = load_models()
# ==========================================
# KAMUS WARNA PERMANEN (KONSISTENSI GRAFIK)
# ==========================================
emotion_color_map = {
'Happy': '#2ecc71', # Hijau Terang
'Neutral': '#95a5a6', # Abu-abu
'Sad': '#3498db', # Biru
'Surprise': '#f1c40f', # Kuning
'Anger': '#e74c3c', # Merah
'Fear': '#9b59b6', # Ungu
'Disgust': '#e67e22' # Oranye
}
# 1. Widget Upload Video
uploaded_video = st.file_uploader("Upload Rekaman Pembelajaran (MP4/MOV)", type=['mp4', 'mov', 'avi'])
if uploaded_video is not None:
# Simpan ke file sementara agar OpenCV bisa baca
tfile = tempfile.NamedTemporaryFile(delete=False, suffix='.mp4')
tfile.write(uploaded_video.read())
tfile.flush() # Pastikan semua data tertulis ke disk
st.video(uploaded_video) # Tampilkan video pratinjau
if st.button("Mulai Analisis", type="primary"):
# Gunakan block try-finally untuk mencegah MEMORY LEAK
try:
cap = cv2.VideoCapture(tfile.name)
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
# --- Ambil FPS dari video untuk perhitungan waktu ---
fps = cap.get(cv2.CAP_PROP_FPS)
if fps == 0 or fps != fps: # Validasi jika metadata FPS kosong/NaN
fps = 30.0
# Inisialisasi hitungan emosi dengan integer 0
emotion_counts = {
'Surprise': 0, 'Fear': 0, 'Disgust': 0,
'Happy': 0, 'Sad': 0, 'Anger': 0, 'Neutral': 0
}
total_detections = 0
timeline_data = []
unique_face_ids = set()
saved_frames_pool = []
# Penanda Progress di Streamlit
status_text = st.empty()
status_text.write("⏳ Sedang melacak wajah dan menganalisis emosi siswa... Mohon tunggu.")
progress_bar = st.progress(0)
frame_idx = 0
# --- KONFIGURASI OPTIMASI CPU ---
FRAME_SKIP = 5 # Analisis 1 dari setiap 5 frame
TARGET_WIDTH = 640 # Mengecilkan resolusi frame
# 3. PROSES LOOPING VIDEO (TWO-STAGE DETECTION + TRACKING)
while cap.isOpened():
ret, frame = cap.read()
if not ret:
break
frame_idx += 1
# Update progress bar
if total_frames > 0:
progress_bar.progress(min(frame_idx / total_frames, 1.0))
# --- OPTIMASI 1: FRAME SKIPPING ---
if frame_idx % FRAME_SKIP != 0:
continue
# --- OPTIMASI 2: RESIZE FRAME ---
h, w = frame.shape[:2]
aspect_ratio = h / w
target_height = int(TARGET_WIDTH * aspect_ratio)
frame = cv2.resize(frame, (TARGET_WIDTH, target_height))
annotated_frame = frame.copy()
face_detected_in_this_frame = False
time_in_seconds = frame_idx / fps
# --- TAHAP 1: Deteksi & Lacak Wajah ---
face_results = face_model.track(frame, conf=0.4, persist=True, verbose=False)
for r_face in face_results:
if r_face.boxes is not None and r_face.boxes.id is not None:
boxes = r_face.boxes.xyxy
track_ids = r_face.boxes.id.int().tolist()
for box, track_id in zip(boxes, track_ids):
unique_face_ids.add(track_id)
# Ambil koordinat kotak pembatas dan cegah keluar batas frame
x1, y1, x2, y2 = map(int, box)
x1, y1 = max(0, x1), max(0, y1)
x2, y2 = min(TARGET_WIDTH, x2), min(target_height, y2)
# Potong area wajah dari frame (Crop)
face_crop = frame[y1:y2, x1:x2]
label_name = "Unknown"
if face_crop.size > 0:
# --- TAHAP 2: Klasifikasi Emosi ---
emotion_results = emotion_model(face_crop, conf=0.4, verbose=False)
for r_emotion in emotion_results:
if len(r_emotion.boxes) > 0:
top_box = r_emotion.boxes[0]
cls_id = int(top_box.cls)
label_name = emotion_model.names[cls_id].capitalize()
# Pastikan format kapitalisasi sesuai dengan dictionary
if label_name in emotion_counts:
emotion_counts[label_name] += 1
total_detections += 1
face_detected_in_this_frame = True
timeline_data.append({
"Waktu (detik)": time_in_seconds,
"Emosi": label_name
})
# Gambar kotak wajah proporsional dan teks emosi
cv2.rectangle(annotated_frame, (x1, y1), (x2, y2), (0, 255, 0), 2)
cv2.putText(annotated_frame, f"ID:{track_id} {label_name}", (x1, y1 - 7),
cv2.FONT_HERSHEY_SIMPLEX, 0.4, (0, 255, 0), 1, cv2.LINE_AA)
# Simpan frame ke pool jika ada emosi yang terdeteksi
if face_detected_in_this_frame and len(saved_frames_pool) < 60:
minutes = int(time_in_seconds // 60)
seconds = int(time_in_seconds % 60)
timestamp_text = f"{minutes:02d}:{seconds:02d}"
cv2.rectangle(annotated_frame, (10, 10), (100, 40), (0, 0, 0), -1)
cv2.putText(annotated_frame, timestamp_text, (15, 33),
cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 255), 2, cv2.LINE_AA)
rgb_frame = cv2.cvtColor(annotated_frame, cv2.COLOR_BGR2RGB)
saved_frames_pool.append(rgb_frame)
cap.release()
# Selesai Proses Scan
status_text.empty()
progress_bar.empty()
st.success("🎉 Analisis Selesai!")
st.write("---")
# --- 4. TAMPILKAN RINGKASAN DATA ---
st.subheader("Ringkasan Hasil Analisis")
col1, col2 = st.columns(2)
with col1:
st.metric(label="Total Wajah/Siswa Terdeteksi", value=f"{len(unique_face_ids)} Orang")
with col2:
st.metric(label="Total Deteksi Emosi", value=f"{total_detections} Kali")
st.write("---")
# --- 5. TAMPILKAN 4 KEY FRAMES HASIL ANALISIS ---
if len(saved_frames_pool) > 0:
st.subheader("Cuplikan Rekaman Analisis (Key Frames)")
img_cols = st.columns(4)
pool_size = len(saved_frames_pool)
if pool_size >= 4:
indices = [0, pool_size // 3, (pool_size * 2) // 3, pool_size - 1]
selected_frames = [saved_frames_pool[i] for i in indices]
else:
selected_frames = saved_frames_pool
for idx, img_frame in enumerate(selected_frames):
with img_cols[idx]:
st.image(img_frame, caption=f"Cuplikan {idx+1}", use_container_width=True)
st.write("---")
# --- 6. HITUNG PERSENTASE & PLOTLY ---
if total_detections > 0:
labels = []
percentages = []
counts = []
for emotion, count in emotion_counts.items():
pct = (count / total_detections) * 100
labels.append(emotion)
percentages.append(round(pct, 1))
counts.append(count)
df = pd.DataFrame({
"Kategori Emosi": labels,
"Persentase (%)": percentages,
"Jumlah Terdeteksi": counts
})
fig = px.bar(
df,
x="Kategori Emosi",
y="Persentase (%)",
text="Persentase (%)",
color="Kategori Emosi",
title="<b>Mood Breakdown (Profil Emosi Kelas)</b>",
color_discrete_map=emotion_color_map, # <-- Terapkan Kamus Warna
hover_data=["Jumlah Terdeteksi"]
)
fig.update_traces(texttemplate='%{text}%', textposition='outside')
fig.update_layout(showlegend=False, yaxis_range=[0, 110])
st.subheader("Grafik Analisis")
st.plotly_chart(fig, use_container_width=True)
st.write("---")
# --- 7. GRAFIK TREN EMOSI BERDASARKAN WAKTU ---
if len(timeline_data) > 0:
st.subheader("Tren Emosi Berdasarkan Waktu")
df_timeline = pd.DataFrame(timeline_data)
max_time = df_timeline["Waktu (detik)"].max()
if max_time <= 120:
bin_size = 10
elif max_time <= 600:
bin_size = 30
else:
bin_size = 60
df_timeline["bin"] = (df_timeline["Waktu (detik)"] // bin_size) * bin_size
df_grouped = (
df_timeline
.groupby(["bin", "Emosi"])
.size()
.reset_index(name="Jumlah Deteksi")
.sort_values("bin")
)
# Konversi ke persentase agar stabil di akhir video
total_per_bin = df_grouped.groupby("bin")["Jumlah Deteksi"].transform('sum')
df_grouped["Persentase (%)"] = (df_grouped["Jumlah Deteksi"] / total_per_bin) * 100
df_grouped["Waktu"] = df_grouped["bin"].apply(
lambda s: f"{int(s // 60):02d}:{int(s % 60):02d}"
)
fig_trend = px.line(
df_grouped,
x="Waktu",
y="Persentase (%)",
color="Emosi",
markers=True,
title=f"<b>Tren Fluktuasi Emosi Kelas per {bin_size} Detik</b>",
labels={"Persentase (%)": "Persentase (%)", "Waktu": "Waktu (MM:SS)"},
color_discrete_map=emotion_color_map, # <-- Terapkan Kamus Warna
)
fig_trend.update_layout(
xaxis_tickangle=-45,
legend_title_text="Emosi",
yaxis_range=[-5, 105]
)
st.plotly_chart(fig_trend, use_container_width=True)
st.write("---")
# --- 8. REKOMENDASI BERBASIS EMOSI DOMINAN ---
st.subheader("Rekomendasi Pengajaran")
st.caption(
"Rekomendasi disusun berdasarkan **Pekrun's Control-Value Theory of Achievement Emotions (2006)** "
"dan prinsip *affective computing* dalam konteks pembelajaran. "
"Gunakan sebagai bahan refleksi, bukan penilaian tunggal."
)
EMOTION_GUIDE = {
"Happy": (
"Positive Activating Emotion",
"Suasana kelas kondusif dan siswa antusias. Pertahankan ritme dan metode pengajaran saat ini. Manfaatkan momentum ini untuk memperkenalkan materi yang lebih menantang.",
"info"
),
"Neutral": (
"Ambiguous State (Focused OR Disengaged)",
"Neutral bisa berarti konsentrasi penuh (flow state) atau kebosanan pasif. Lakukan pengecekan pemahaman (quick poll/pertanyaan lisan) untuk memastikan siswa benar-benar mengikuti, bukan sekadar diam.",
"info"
),
"Surprise": (
"Positive/Negative Activating Emotion",
"Kejutan bisa menandakan momen 'aha' (positif) atau kebingungan mendadak (negatif). Perhatikan konteks: apakah muncul saat materi baru diperkenalkan? Jika ya, manfaatkan sebagai jembatan diskusi.",
"info"
),
"Sad": (
"Negative Deactivating Emotion",
"Emosi ini mengindikasikan rendahnya motivasi atau rasa tidak mampu. Berikan penguatan positif (positive reinforcement), kecilkan target sementara, dan pastikan siswa merasa aman untuk bertanya.",
"warning"
),
"Anger": (
"Negative Activating Emotion (Frustration)",
"Umumnya muncul akibat frustrasi terhadap materi yang terlalu sulit atau merasa tidak diperlakukan adil. Evaluasi kembali tingkat kesulitan soal/materi dan beri ruang bagi siswa untuk mengekspresikan kesulitannya.",
"warning"
),
"Fear": (
"Negative Activating Emotion (Anxiety)",
"Kecemasan akademik dapat secara langsung menghambat proses kognitif. Kurangi tekanan evaluasi, normalkan kesalahan sebagai bagian dari belajar, dan pertimbangkan aktivitas low-stakes sebelum penilaian utama.",
"warning"
),
"Disgust": (
"Strong Negative Emotion",
"Emosi kuat yang bisa menandakan siswa merasa konten tidak relevan atau pendekatan pengajaran kurang sesuai. Tinjau kembali relevansi materi dengan konteks kehidupan siswa.",
"warning"
)
}
max_emotion = df.loc[df['Persentase (%)'].idxmax()]['Kategori Emosi']
max_pct = df['Persentase (%)'].max()
guide = EMOTION_GUIDE.get(max_emotion)
if guide:
interpretation, suggestion, msg_type = guide
message = (
f"**Emosi Dominan:** **{max_emotion} ({max_pct}%)** "
f"— dikategorikan sebagai *{interpretation}*\n\n"
f"**Saran:** {suggestion}\n\n"
f"**Catatan:** Data dari ±{len(unique_face_ids)} wajah unik yang tertangkap kamera."
)
if msg_type == "info":
st.info(message)
else:
st.warning(message)
else:
st.error("❌ Tidak ada wajah siswa yang terdeteksi di dalam video. Pastikan kualitas video cukup jelas.")
# --- PENINGKATAN 1: PENGHAPUSAN FILE SEMENTARA ---
finally:
if os.path.exists(tfile.name):
os.remove(tfile.name)