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( """ """, 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="Mood Breakdown (Profil Emosi Kelas)", 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"Tren Fluktuasi Emosi Kelas per {bin_size} Detik", 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)