File size: 17,659 Bytes
eee8323
 
 
 
 
 
dc95aa8
148afcf
 
 
 
 
dc95aa8
8a64d93
 
 
a7e51c5
 
 
 
 
 
 
 
 
 
 
 
8a64d93
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dc95aa8
148afcf
 
dc95aa8
148afcf
 
8a64d93
148afcf
8a64d93
148afcf
 
 
8a64d93
 
 
 
 
148afcf
8a64d93
 
 
 
148afcf
8a64d93
 
 
 
 
c6472e1
8a64d93
 
 
 
c6472e1
8a64d93
 
 
 
d56ce66
8a64d93
148afcf
8a64d93
 
 
d56ce66
8a64d93
 
 
 
 
 
 
 
 
 
 
1922871
8a64d93
 
 
1922871
8a64d93
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d56ce66
8a64d93
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d56ce66
8a64d93
 
 
 
 
575dee4
8a64d93
 
 
 
 
 
 
 
d56ce66
 
148afcf
8a64d93
 
 
 
148afcf
8a64d93
 
 
 
 
 
 
 
 
 
 
a792f1d
8a64d93
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bbad393
8a64d93
 
 
bbad393
8a64d93
 
 
 
 
 
 
 
 
bbad393
8a64d93
 
 
 
 
 
 
 
bbad393
8a64d93
 
 
bbad393
8a64d93
 
 
bbad393
8a64d93
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a792f1d
bbad393
8a64d93
 
 
 
 
 
 
 
 
 
 
6f4ff29
8a64d93
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bbad393
8a64d93
 
bbad393
8a64d93
a792f1d
8a64d93
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
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
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
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
266
267
268
269
270
271
272
273
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
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
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)