achmadichzan commited on
Commit
575dee4
·
verified ·
1 Parent(s): bbad393

Update src/streamlit_app.py

Browse files
Files changed (1) hide show
  1. 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.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
+ 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)