achmadichzan commited on
Commit
8a64d93
·
verified ·
1 Parent(s): a792f1d

Update src/streamlit_app.py

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