Spaces:
Sleeping
Sleeping
Update src/streamlit_app.py
Browse files- src/streamlit_app.py +293 -304
src/streamlit_app.py
CHANGED
|
@@ -11,344 +11,333 @@ import pandas as pd
|
|
| 11 |
import plotly.express as px
|
| 12 |
from ultralytics import YOLO
|
| 13 |
|
| 14 |
-
st.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
# 1. Widget Upload Video
|
| 17 |
uploaded_video = st.file_uploader("Upload Rekaman Pembelajaran (MP4/MOV)", type=['mp4', 'mov', 'avi'])
|
| 18 |
|
| 19 |
if uploaded_video is not None:
|
| 20 |
# Simpan ke file sementara agar OpenCV bisa baca
|
| 21 |
-
tfile = tempfile.NamedTemporaryFile(delete=False)
|
| 22 |
tfile.write(uploaded_video.read())
|
|
|
|
| 23 |
|
| 24 |
st.video(uploaded_video) # Tampilkan video pratinjau
|
| 25 |
|
| 26 |
-
if st.button("Mulai Analisis"):
|
| 27 |
-
#
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
cap = cv2.VideoCapture(tfile.name)
|
| 32 |
-
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
| 33 |
-
|
| 34 |
-
# --- Ambil FPS dari video untuk perhitungan waktu ---
|
| 35 |
-
fps = cap.get(cv2.CAP_PROP_FPS)
|
| 36 |
-
if fps == 0 or fps != fps: # Validasi jika metadata FPS kosong/NaN
|
| 37 |
-
fps = 30.0
|
| 38 |
-
|
| 39 |
-
# Inisialisasi hitungan emosi
|
| 40 |
-
emotion_counts = {
|
| 41 |
-
"Happy": 0, "Neutral": 0, "Angry": 0, "Contempt": 0,
|
| 42 |
-
"Sad": 0, "Surprised": 0, "Fear": 0, "Disgust": 0
|
| 43 |
-
}
|
| 44 |
-
total_detections = 0
|
| 45 |
-
timeline_data = []
|
| 46 |
-
|
| 47 |
-
# Set untuk menyimpan ID wajah unik
|
| 48 |
-
unique_face_ids = set()
|
| 49 |
-
|
| 50 |
-
# List untuk menampung frame hasil deteksi (maksimal 60 frame agar hemat RAM)
|
| 51 |
-
saved_frames_pool = []
|
| 52 |
-
|
| 53 |
-
# Penanda Progress di Streamlit
|
| 54 |
-
status_text = st.empty()
|
| 55 |
-
status_text.write("⏳ Sedang melacak wajah dan menganalisis emosi siswa... Mohon tunggu.")
|
| 56 |
-
progress_bar = st.progress(0)
|
| 57 |
-
|
| 58 |
-
frame_idx = 0
|
| 59 |
-
|
| 60 |
-
# --- KONFIGURASI OPTIMASI CPU ---
|
| 61 |
-
FRAME_SKIP = 5 # Analisis 1 dari setiap 5 frame
|
| 62 |
-
TARGET_WIDTH = 640 # Mengecilkan resolusi frame
|
| 63 |
-
|
| 64 |
-
# 3. PROSES LOOPING VIDEO (TWO-STAGE DETECTION + TRACKING)
|
| 65 |
-
while cap.isOpened():
|
| 66 |
-
ret, frame = cap.read()
|
| 67 |
-
if not ret:
|
| 68 |
-
break
|
| 69 |
|
| 70 |
-
|
| 71 |
-
|
|
|
|
|
|
|
| 72 |
|
| 73 |
-
#
|
| 74 |
-
|
| 75 |
-
|
|
|
|
|
|
|
| 76 |
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
frame = cv2.resize(frame, (TARGET_WIDTH, target_height))
|
| 82 |
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
|
|
|
| 86 |
|
| 87 |
-
|
| 88 |
-
face_results = face_model.track(frame, conf=0.4, persist=True, verbose=False)
|
| 89 |
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
track_ids = r_face.boxes.id.int().tolist()
|
| 94 |
-
|
| 95 |
-
for box, track_id in zip(boxes, track_ids):
|
| 96 |
-
unique_face_ids.add(track_id)
|
| 97 |
-
|
| 98 |
-
# Ambil koordinat kotak pembatas dan cegah keluar batas frame
|
| 99 |
-
x1, y1, x2, y2 = map(int, box)
|
| 100 |
-
x1, y1 = max(0, x1), max(0, y1)
|
| 101 |
-
x2, y2 = min(TARGET_WIDTH, x2), min(target_height, y2)
|
| 102 |
-
|
| 103 |
-
# Potong area wajah dari frame (Crop)
|
| 104 |
-
face_crop = frame[y1:y2, x1:x2]
|
| 105 |
-
|
| 106 |
-
# Label default jika model emosi kurang yakin (< 0.40)
|
| 107 |
-
label_name = "Unknown"
|
| 108 |
-
|
| 109 |
-
if face_crop.size > 0:
|
| 110 |
-
# --- TAHAP 2: Klasifikasi Emosi ---
|
| 111 |
-
emotion_results = emotion_model(face_crop, conf=0.4, verbose=False)
|
| 112 |
-
|
| 113 |
-
for r_emotion in emotion_results:
|
| 114 |
-
if len(r_emotion.boxes) > 0:
|
| 115 |
-
top_box = r_emotion.boxes[0]
|
| 116 |
-
cls_id = int(top_box.cls)
|
| 117 |
-
label_name = emotion_model.names[cls_id].capitalize()
|
| 118 |
-
|
| 119 |
-
if label_name in emotion_counts:
|
| 120 |
-
emotion_counts[label_name] += 1
|
| 121 |
-
total_detections += 1
|
| 122 |
-
face_detected_in_this_frame = True
|
| 123 |
-
timeline_data.append({
|
| 124 |
-
"Waktu (detik)": time_in_seconds,
|
| 125 |
-
"Emosi": label_name
|
| 126 |
-
})
|
| 127 |
-
|
| 128 |
-
# Gambar kotak wajah proporsional dan teks emosi
|
| 129 |
-
cv2.rectangle(annotated_frame, (x1, y1), (x2, y2), (0, 255, 0), 2)
|
| 130 |
-
cv2.putText(annotated_frame, f"ID:{track_id} {label_name}", (x1, y1 - 7),
|
| 131 |
-
cv2.FONT_HERSHEY_SIMPLEX, 0.4, (0, 255, 0), 1, cv2.LINE_AA)
|
| 132 |
|
| 133 |
-
#
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 255), 2, cv2.LINE_AA)
|
| 144 |
|
| 145 |
-
#
|
| 146 |
-
|
| 147 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
st.
|
| 171 |
-
img_cols = st.columns(4)
|
| 172 |
|
| 173 |
-
#
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
|
|
|
| 180 |
|
| 181 |
-
for idx, img_frame in enumerate(selected_frames):
|
| 182 |
-
with img_cols[idx]:
|
| 183 |
-
st.image(img_frame, caption=f"Cuplikan {idx+1}", use_container_width=True)
|
| 184 |
st.write("---")
|
| 185 |
-
|
| 186 |
-
# --- 6. HITUNG PERSENTASE & PLOTLY ---
|
| 187 |
-
if total_detections > 0:
|
| 188 |
-
labels = []
|
| 189 |
-
percentages = []
|
| 190 |
-
counts = []
|
| 191 |
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
counts.append(count)
|
| 197 |
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
color="Kategori Emosi",
|
| 210 |
-
title="<b>Mood Breakdown (Profil Emosi Kelas)</b>",
|
| 211 |
-
color_discrete_sequence=px.colors.qualitative.Pastel,
|
| 212 |
-
hover_data=["Jumlah Terdeteksi (Wajah x Frame)"]
|
| 213 |
-
)
|
| 214 |
-
|
| 215 |
-
fig.update_traces(texttemplate='%{text}%', textposition='outside')
|
| 216 |
-
fig.update_layout(showlegend=False, yaxis_range=[0, 110])
|
| 217 |
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
|
| 226 |
-
|
|
|
|
|
|
|
| 227 |
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
|
|
|
| 236 |
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
)
|
| 250 |
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
)
|
| 265 |
-
st.plotly_chart(fig_trend, use_container_width=True)
|
| 266 |
-
st.write("---")
|
| 267 |
-
|
| 268 |
-
# --- 8. REKOMENDASI BERBASIS EMOSI DOMINAN ---
|
| 269 |
-
st.subheader("Rekomendasi Pengajaran")
|
| 270 |
-
st.caption(
|
| 271 |
-
"Rekomendasi disusun berdasarkan **Pekrun's Control-Value Theory of Achievement Emotions (2006)** "
|
| 272 |
-
"dan prinsip *affective computing* dalam konteks pembelajaran. "
|
| 273 |
-
"Gunakan sebagai bahan refleksi, bukan penilaian tunggal."
|
| 274 |
-
)
|
| 275 |
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
"
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
"
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
"
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
"
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
"
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
"Fear": (
|
| 314 |
-
"Negative Activating Emotion (Anxiety)",
|
| 315 |
-
"Kecemasan akademik dapat secara langsung menghambat proses kognitif. "
|
| 316 |
-
"Kurangi tekanan evaluasi, normalkan kesalahan sebagai bagian dari belajar, "
|
| 317 |
-
"dan pertimbangkan aktivitas low-stakes sebelum penilaian utama.",
|
| 318 |
-
"warning"
|
| 319 |
-
),
|
| 320 |
-
"Disgust": (
|
| 321 |
-
"Strong Negative Emotion",
|
| 322 |
-
"Emosi kuat yang bisa menandakan siswa merasa konten tidak relevan atau "
|
| 323 |
-
"pendekatan pengajaran kurang sesuai. Tinjau kembali relevansi materi "
|
| 324 |
-
"dengan konteks kehidupan siswa.",
|
| 325 |
-
"warning"
|
| 326 |
-
),
|
| 327 |
-
"Contempt": (
|
| 328 |
-
"Strong Negative Emotion (Disengagement)",
|
| 329 |
-
"Sinyal disengagement yang serius. Pertimbangkan pendekatan yang lebih "
|
| 330 |
-
"student-centered, libatkan siswa dalam menentukan arah diskusi, "
|
| 331 |
-
"atau cari tahu hambatan non-akademis yang mungkin ada.",
|
| 332 |
-
"warning"
|
| 333 |
-
),
|
| 334 |
-
}
|
| 335 |
|
| 336 |
-
|
| 337 |
-
|
| 338 |
|
| 339 |
-
|
| 340 |
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
import plotly.express as px
|
| 12 |
from ultralytics import YOLO
|
| 13 |
|
| 14 |
+
st.set_page_config(page_title="EduReflect", page_icon="🎓", layout="wide")
|
| 15 |
+
st.title("EduReflect: Analisis Emosi Kelas 🎓")
|
| 16 |
+
st.markdown("Unggah rekaman video suasana kelas untuk menganalisis metrik emosi siswa menggunakan AI.")
|
| 17 |
+
|
| 18 |
+
# --- PENINGKATAN 4: CACHING MODEL ---
|
| 19 |
+
# Model hanya di-load 1 kali saat server nyala, menghemat RAM dan Waktu
|
| 20 |
+
@st.cache_resource
|
| 21 |
+
def load_models():
|
| 22 |
+
# Pastikan file model ada di dalam folder 'src' di repository kamu
|
| 23 |
+
face = YOLO('src/yolov11n-face.onnx', task='detect')
|
| 24 |
+
emotion = YOLO('src/best_int8_openvino_model', task='detect')
|
| 25 |
+
return face, emotion
|
| 26 |
+
|
| 27 |
+
# Load model di awal
|
| 28 |
+
face_model, emotion_model = load_models()
|
| 29 |
|
| 30 |
# 1. Widget Upload Video
|
| 31 |
uploaded_video = st.file_uploader("Upload Rekaman Pembelajaran (MP4/MOV)", type=['mp4', 'mov', 'avi'])
|
| 32 |
|
| 33 |
if uploaded_video is not None:
|
| 34 |
# Simpan ke file sementara agar OpenCV bisa baca
|
| 35 |
+
tfile = tempfile.NamedTemporaryFile(delete=False, suffix='.mp4')
|
| 36 |
tfile.write(uploaded_video.read())
|
| 37 |
+
tfile.flush() # Pastikan semua data tertulis ke disk
|
| 38 |
|
| 39 |
st.video(uploaded_video) # Tampilkan video pratinjau
|
| 40 |
|
| 41 |
+
if st.button("Mulai Analisis", type="primary"):
|
| 42 |
+
# Gunakan block try-finally untuk mencegah MEMORY LEAK (Peningkatan 1)
|
| 43 |
+
try:
|
| 44 |
+
cap = cv2.VideoCapture(tfile.name)
|
| 45 |
+
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
|
| 47 |
+
# --- Ambil FPS dari video untuk perhitungan waktu ---
|
| 48 |
+
fps = cap.get(cv2.CAP_PROP_FPS)
|
| 49 |
+
if fps == 0 or fps != fps: # Validasi jika metadata FPS kosong/NaN
|
| 50 |
+
fps = 30.0
|
| 51 |
|
| 52 |
+
# PERBAIKAN BUG: Inisialisasi hitungan emosi dengan integer 0
|
| 53 |
+
emotion_counts = {
|
| 54 |
+
'Surprise': 0, 'Fear': 0, 'Disgust': 0,
|
| 55 |
+
'Happy': 0, 'Sad': 0, 'Anger': 0, 'Neutral': 0
|
| 56 |
+
}
|
| 57 |
|
| 58 |
+
total_detections = 0
|
| 59 |
+
timeline_data = []
|
| 60 |
+
unique_face_ids = set()
|
| 61 |
+
saved_frames_pool = []
|
|
|
|
| 62 |
|
| 63 |
+
# Penanda Progress di Streamlit
|
| 64 |
+
status_text = st.empty()
|
| 65 |
+
status_text.write("⏳ Sedang melacak wajah dan menganalisis emosi siswa... Mohon tunggu.")
|
| 66 |
+
progress_bar = st.progress(0)
|
| 67 |
|
| 68 |
+
frame_idx = 0
|
|
|
|
| 69 |
|
| 70 |
+
# --- KONFIGURASI OPTIMASI CPU ---
|
| 71 |
+
FRAME_SKIP = 5 # Analisis 1 dari setiap 5 frame
|
| 72 |
+
TARGET_WIDTH = 640 # Mengecilkan resolusi frame
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
|
| 74 |
+
# 3. PROSES LOOPING VIDEO (TWO-STAGE DETECTION + TRACKING)
|
| 75 |
+
while cap.isOpened():
|
| 76 |
+
ret, frame = cap.read()
|
| 77 |
+
if not ret:
|
| 78 |
+
break
|
| 79 |
+
|
| 80 |
+
frame_idx += 1
|
| 81 |
+
|
| 82 |
+
# Update progress bar
|
| 83 |
+
if total_frames > 0:
|
| 84 |
+
progress_bar.progress(min(frame_idx / total_frames, 1.0))
|
| 85 |
+
|
| 86 |
+
# --- OPTIMASI 1: FRAME SKIPPING ---
|
| 87 |
+
if frame_idx % FRAME_SKIP != 0:
|
| 88 |
+
continue
|
| 89 |
+
|
| 90 |
+
# --- OPTIMASI 2: RESIZE FRAME ---
|
| 91 |
+
h, w = frame.shape[:2]
|
| 92 |
+
aspect_ratio = h / w
|
| 93 |
+
target_height = int(TARGET_WIDTH * aspect_ratio)
|
| 94 |
+
frame = cv2.resize(frame, (TARGET_WIDTH, target_height))
|
| 95 |
|
| 96 |
+
annotated_frame = frame.copy()
|
| 97 |
+
face_detected_in_this_frame = False
|
| 98 |
+
time_in_seconds = frame_idx / fps
|
|
|
|
| 99 |
|
| 100 |
+
# --- TAHAP 1: Deteksi & Lacak Wajah ---
|
| 101 |
+
face_results = face_model.track(frame, conf=0.4, persist=True, verbose=False)
|
| 102 |
+
|
| 103 |
+
for r_face in face_results:
|
| 104 |
+
if r_face.boxes is not None and r_face.boxes.id is not None:
|
| 105 |
+
boxes = r_face.boxes.xyxy
|
| 106 |
+
track_ids = r_face.boxes.id.int().tolist()
|
| 107 |
+
|
| 108 |
+
for box, track_id in zip(boxes, track_ids):
|
| 109 |
+
unique_face_ids.add(track_id)
|
| 110 |
+
|
| 111 |
+
# Ambil koordinat kotak pembatas dan cegah keluar batas frame
|
| 112 |
+
x1, y1, x2, y2 = map(int, box)
|
| 113 |
+
x1, y1 = max(0, x1), max(0, y1)
|
| 114 |
+
x2, y2 = min(TARGET_WIDTH, x2), min(target_height, y2)
|
| 115 |
+
|
| 116 |
+
# Potong area wajah dari frame (Crop)
|
| 117 |
+
face_crop = frame[y1:y2, x1:x2]
|
| 118 |
+
label_name = "Unknown"
|
| 119 |
+
|
| 120 |
+
if face_crop.size > 0:
|
| 121 |
+
# --- TAHAP 2: Klasifikasi Emosi ---
|
| 122 |
+
emotion_results = emotion_model(face_crop, conf=0.4, verbose=False)
|
| 123 |
|
| 124 |
+
for r_emotion in emotion_results:
|
| 125 |
+
if len(r_emotion.boxes) > 0:
|
| 126 |
+
top_box = r_emotion.boxes[0]
|
| 127 |
+
cls_id = int(top_box.cls)
|
| 128 |
+
label_name = emotion_model.names[cls_id].capitalize()
|
| 129 |
+
|
| 130 |
+
# Pastikan format kapitalisasi sesuai dengan dictionary
|
| 131 |
+
if label_name in emotion_counts:
|
| 132 |
+
emotion_counts[label_name] += 1
|
| 133 |
+
total_detections += 1
|
| 134 |
+
face_detected_in_this_frame = True
|
| 135 |
+
timeline_data.append({
|
| 136 |
+
"Waktu (detik)": time_in_seconds,
|
| 137 |
+
"Emosi": label_name
|
| 138 |
+
})
|
| 139 |
+
|
| 140 |
+
# Gambar kotak wajah proporsional dan teks emosi
|
| 141 |
+
cv2.rectangle(annotated_frame, (x1, y1), (x2, y2), (0, 255, 0), 2)
|
| 142 |
+
cv2.putText(annotated_frame, f"ID:{track_id} {label_name}", (x1, y1 - 7),
|
| 143 |
+
cv2.FONT_HERSHEY_SIMPLEX, 0.4, (0, 255, 0), 1, cv2.LINE_AA)
|
| 144 |
+
|
| 145 |
+
# Simpan frame ke pool jika ada emosi yang terdeteksi
|
| 146 |
+
if face_detected_in_this_frame and len(saved_frames_pool) < 60:
|
| 147 |
+
minutes = int(time_in_seconds // 60)
|
| 148 |
+
seconds = int(time_in_seconds % 60)
|
| 149 |
+
timestamp_text = f"{minutes:02d}:{seconds:02d}"
|
| 150 |
+
|
| 151 |
+
cv2.rectangle(annotated_frame, (10, 10), (100, 40), (0, 0, 0), -1)
|
| 152 |
+
cv2.putText(annotated_frame, timestamp_text, (15, 33),
|
| 153 |
+
cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 255), 2, cv2.LINE_AA)
|
| 154 |
+
|
| 155 |
+
rgb_frame = cv2.cvtColor(annotated_frame, cv2.COLOR_BGR2RGB)
|
| 156 |
+
saved_frames_pool.append(rgb_frame)
|
| 157 |
+
|
| 158 |
+
cap.release()
|
| 159 |
|
| 160 |
+
# Selesai Proses Scan
|
| 161 |
+
status_text.empty()
|
| 162 |
+
progress_bar.empty()
|
| 163 |
+
st.success("🎉 Analisis Selesai!")
|
| 164 |
+
st.write("---")
|
|
|
|
| 165 |
|
| 166 |
+
# --- 4. TAMPILKAN RINGKASAN DATA ---
|
| 167 |
+
st.subheader("Ringkasan Hasil Analisis")
|
| 168 |
+
|
| 169 |
+
col1, col2 = st.columns(2)
|
| 170 |
+
with col1:
|
| 171 |
+
st.metric(label="Total Wajah/Siswa Terdeteksi", value=f"{len(unique_face_ids)} Orang")
|
| 172 |
+
with col2:
|
| 173 |
+
st.metric(label="Total Deteksi Emosi", value=f"{total_detections} Kali")
|
| 174 |
|
|
|
|
|
|
|
|
|
|
| 175 |
st.write("---")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
|
| 177 |
+
# --- 5. TAMPILKAN 4 KEY FRAMES HASIL ANALISIS ---
|
| 178 |
+
if len(saved_frames_pool) > 0:
|
| 179 |
+
st.subheader("Cuplikan Rekaman Analisis (Key Frames)")
|
| 180 |
+
img_cols = st.columns(4)
|
|
|
|
| 181 |
|
| 182 |
+
pool_size = len(saved_frames_pool)
|
| 183 |
+
if pool_size >= 4:
|
| 184 |
+
indices = [0, pool_size // 3, (pool_size * 2) // 3, pool_size - 1]
|
| 185 |
+
selected_frames = [saved_frames_pool[i] for i in indices]
|
| 186 |
+
else:
|
| 187 |
+
selected_frames = saved_frames_pool
|
| 188 |
+
|
| 189 |
+
for idx, img_frame in enumerate(selected_frames):
|
| 190 |
+
with img_cols[idx]:
|
| 191 |
+
st.image(img_frame, caption=f"Cuplikan {idx+1}", use_container_width=True)
|
| 192 |
+
st.write("---")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
|
| 194 |
+
# --- 6. HITUNG PERSENTASE & PLOTLY ---
|
| 195 |
+
if total_detections > 0:
|
| 196 |
+
labels = []
|
| 197 |
+
percentages = []
|
| 198 |
+
counts = []
|
| 199 |
+
|
| 200 |
+
for emotion, count in emotion_counts.items():
|
| 201 |
+
pct = (count / total_detections) * 100
|
| 202 |
+
labels.append(emotion)
|
| 203 |
+
percentages.append(round(pct, 1))
|
| 204 |
+
counts.append(count)
|
| 205 |
+
|
| 206 |
+
df = pd.DataFrame({
|
| 207 |
+
"Kategori Emosi": labels,
|
| 208 |
+
"Persentase (%)": percentages,
|
| 209 |
+
"Jumlah Terdeteksi": counts
|
| 210 |
+
})
|
| 211 |
+
|
| 212 |
+
fig = px.bar(
|
| 213 |
+
df,
|
| 214 |
+
x="Kategori Emosi",
|
| 215 |
+
y="Persentase (%)",
|
| 216 |
+
text="Persentase (%)",
|
| 217 |
+
color="Kategori Emosi",
|
| 218 |
+
title="<b>Mood Breakdown (Profil Emosi Kelas)</b>",
|
| 219 |
+
color_discrete_sequence=px.colors.qualitative.Pastel,
|
| 220 |
+
hover_data=["Jumlah Terdeteksi"]
|
| 221 |
+
)
|
| 222 |
+
|
| 223 |
+
fig.update_traces(texttemplate='%{text}%', textposition='outside')
|
| 224 |
+
fig.update_layout(showlegend=False, yaxis_range=[0, 110])
|
| 225 |
+
|
| 226 |
+
st.subheader("Grafik Analisis")
|
| 227 |
+
st.plotly_chart(fig, use_container_width=True)
|
| 228 |
+
st.write("---")
|
| 229 |
|
| 230 |
+
# --- 7. GRAFIK TREN EMOSI BERDASARKAN WAKTU ---
|
| 231 |
+
if len(timeline_data) > 0:
|
| 232 |
+
st.subheader("Tren Emosi Berdasarkan Waktu")
|
| 233 |
|
| 234 |
+
df_timeline = pd.DataFrame(timeline_data)
|
| 235 |
+
max_time = df_timeline["Waktu (detik)"].max()
|
| 236 |
+
|
| 237 |
+
if max_time <= 120:
|
| 238 |
+
bin_size = 10
|
| 239 |
+
elif max_time <= 600:
|
| 240 |
+
bin_size = 30
|
| 241 |
+
else:
|
| 242 |
+
bin_size = 60
|
| 243 |
|
| 244 |
+
df_timeline["bin"] = (df_timeline["Waktu (detik)"] // bin_size) * bin_size
|
| 245 |
+
df_grouped = (
|
| 246 |
+
df_timeline
|
| 247 |
+
.groupby(["bin", "Emosi"])
|
| 248 |
+
.size()
|
| 249 |
+
.reset_index(name="Jumlah Deteksi")
|
| 250 |
+
.sort_values("bin")
|
| 251 |
+
)
|
| 252 |
|
| 253 |
+
df_grouped["Waktu"] = df_grouped["bin"].apply(
|
| 254 |
+
lambda s: f"{int(s // 60):02d}:{int(s % 60):02d}"
|
| 255 |
+
)
|
|
|
|
| 256 |
|
| 257 |
+
fig_trend = px.line(
|
| 258 |
+
df_grouped,
|
| 259 |
+
x="Waktu",
|
| 260 |
+
y="Jumlah Deteksi",
|
| 261 |
+
color="Emosi",
|
| 262 |
+
markers=True,
|
| 263 |
+
title=f"<b>Tren Emosi Kelas per {bin_size} Detik</b>",
|
| 264 |
+
labels={"Jumlah Deteksi": "Jumlah Deteksi", "Waktu": "Waktu (MM:SS)"},
|
| 265 |
+
color_discrete_sequence=px.colors.qualitative.Pastel,
|
| 266 |
+
)
|
| 267 |
+
fig_trend.update_layout(
|
| 268 |
+
xaxis_tickangle=-45,
|
| 269 |
+
legend_title_text="Emosi",
|
| 270 |
+
)
|
| 271 |
+
st.plotly_chart(fig_trend, use_container_width=True)
|
| 272 |
+
st.write("---")
|
| 273 |
+
|
| 274 |
+
# --- 8. REKOMENDASI BERBASIS EMOSI DOMINAN ---
|
| 275 |
+
st.subheader("Rekomendasi Pengajaran")
|
| 276 |
+
st.caption(
|
| 277 |
+
"Rekomendasi disusun berdasarkan **Pekrun's Control-Value Theory of Achievement Emotions (2006)** "
|
| 278 |
+
"dan prinsip *affective computing* dalam konteks pembelajaran. "
|
| 279 |
+
"Gunakan sebagai bahan refleksi, bukan penilaian tunggal."
|
| 280 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 281 |
|
| 282 |
+
EMOTION_GUIDE = {
|
| 283 |
+
"Happy": (
|
| 284 |
+
"Positive Activating Emotion",
|
| 285 |
+
"Suasana kelas kondusif dan siswa antusias. Pertahankan ritme dan metode pengajaran saat ini. Manfaatkan momentum ini untuk memperkenalkan materi yang lebih menantang.",
|
| 286 |
+
"info"
|
| 287 |
+
),
|
| 288 |
+
"Neutral": (
|
| 289 |
+
"Ambiguous State (Focused OR Disengaged)",
|
| 290 |
+
"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.",
|
| 291 |
+
"info"
|
| 292 |
+
),
|
| 293 |
+
"Surprised": (
|
| 294 |
+
"Positive/Negative Activating Emotion",
|
| 295 |
+
"Kejutan bisa menandakan momen 'aha' (positif) atau kebingungan mendadak (negatif). Perhatikan konteks: apakah muncul saat materi baru diperkenalkan? Jika ya, manfaatkan sebagai jembatan diskusi.",
|
| 296 |
+
"info"
|
| 297 |
+
),
|
| 298 |
+
"Sad": (
|
| 299 |
+
"Negative Deactivating Emotion",
|
| 300 |
+
"Emosi ini mengindikasikan rendahnya motivasi atau rasa tidak mampu. Berikan penguatan positif (positive reinforcement), kecilkan target sementara, dan pastikan siswa merasa aman untuk bertanya.",
|
| 301 |
+
"warning"
|
| 302 |
+
),
|
| 303 |
+
"Anger": ( # Pastikan penamaan sama dengan dictionary (Anger, bukan Angry)
|
| 304 |
+
"Negative Activating Emotion (Frustration)",
|
| 305 |
+
"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.",
|
| 306 |
+
"warning"
|
| 307 |
+
),
|
| 308 |
+
"Fear": (
|
| 309 |
+
"Negative Activating Emotion (Anxiety)",
|
| 310 |
+
"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.",
|
| 311 |
+
"warning"
|
| 312 |
+
),
|
| 313 |
+
"Disgust": (
|
| 314 |
+
"Strong Negative Emotion",
|
| 315 |
+
"Emosi kuat yang bisa menandakan siswa merasa konten tidak relevan atau pendekatan pengajaran kurang sesuai. Tinjau kembali relevansi materi dengan konteks kehidupan siswa.",
|
| 316 |
+
"warning"
|
| 317 |
+
)
|
| 318 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 319 |
|
| 320 |
+
max_emotion = df.loc[df['Persentase (%)'].idxmax()]['Kategori Emosi']
|
| 321 |
+
max_pct = df['Persentase (%)'].max()
|
| 322 |
|
| 323 |
+
guide = EMOTION_GUIDE.get(max_emotion)
|
| 324 |
|
| 325 |
+
if guide:
|
| 326 |
+
interpretation, suggestion, msg_type = guide
|
| 327 |
+
message = (
|
| 328 |
+
f"**Emosi Dominan:** **{max_emotion} ({max_pct}%)** "
|
| 329 |
+
f"— dikategorikan sebagai *{interpretation}*\n\n"
|
| 330 |
+
f"**Saran:** {suggestion}\n\n"
|
| 331 |
+
f"**Catatan:** Data dari ±{len(unique_face_ids)} wajah unik yang tertangkap kamera."
|
| 332 |
+
)
|
| 333 |
+
if msg_type == "info":
|
| 334 |
+
st.info(message)
|
| 335 |
+
else:
|
| 336 |
+
st.warning(message)
|
| 337 |
+
else:
|
| 338 |
+
st.error("❌ Tidak ada wajah siswa yang terdeteksi di dalam video. Pastikan kualitas video cukup jelas.")
|
| 339 |
+
|
| 340 |
+
# --- PENINGKATAN 1: PENGHAPUSAN FILE SEMENTARA ---
|
| 341 |
+
finally:
|
| 342 |
+
if os.path.exists(tfile.name):
|
| 343 |
+
os.remove(tfile.name)
|