Spaces:
Running
Running
| """EmoSphere Session Report — visual summary after a live session. | |
| Generates: | |
| - Emotion timeline chart (Plotly) | |
| - Dominant emotion distribution (pie chart) | |
| - Emotional shifts/peaks | |
| - Trigger words and topics | |
| - Full transcript with emotional highlights | |
| - Modality contribution breakdown | |
| - Session statistics | |
| """ | |
| from __future__ import annotations | |
| from typing import Optional | |
| import streamlit as st | |
| from models import EmotionLabel, EMOTION_LABELS, EMOTION_EMOJI | |
| from live_processor import LiveSessionProcessor, SessionSummary, TimelineEntry, TranscriptSegment | |
| # ── Color Palette (matches app.py) ─────────────────────────────────── | |
| EMOTION_COLORS = { | |
| EmotionLabel.JOY: "#FFD700", | |
| EmotionLabel.SADNESS: "#4A90D9", | |
| EmotionLabel.SURPRISE: "#FF8C00", | |
| EmotionLabel.FEAR: "#8B5CF6", | |
| EmotionLabel.DISGUST: "#10B981", | |
| EmotionLabel.NEUTRAL: "#94A3B8", | |
| EmotionLabel.LOVE: "#F472B6", | |
| EmotionLabel.CALM: "#67E8F9", | |
| } | |
| def render_session_report( | |
| processor: LiveSessionProcessor, | |
| summary: Optional[SessionSummary] = None, | |
| ): | |
| """Render the full session report in Streamlit.""" | |
| if summary is None: | |
| summary = processor.generate_summary() | |
| if summary is None: | |
| st.warning("No session data available to generate a report.") | |
| return | |
| timeline = processor.get_timeline() | |
| transcript = processor.get_transcript() | |
| st.markdown( | |
| '<div style="text-align: center; margin: 20px 0;">' | |
| '<h2>Session Report</h2>' | |
| '<p style="color: #6B7B9D;">Multimodal emotion analysis summary</p>' | |
| '</div>', | |
| unsafe_allow_html=True, | |
| ) | |
| # ── Statistics Row ─────────────────────────────────────────────── | |
| cols = st.columns(4) | |
| with cols[0]: | |
| mins = int(summary.duration_seconds) // 60 | |
| secs = int(summary.duration_seconds) % 60 | |
| st.metric("Duration", f"{mins}:{secs:02d}") | |
| with cols[1]: | |
| st.metric("Video Frames", str(summary.total_video_frames)) | |
| with cols[2]: | |
| st.metric("Audio Chunks", str(summary.total_audio_chunks)) | |
| with cols[3]: | |
| emoji = EMOTION_EMOJI.get(summary.dominant_emotion, "") | |
| st.metric("Dominant Emotion", f"{emoji} {summary.dominant_emotion.value}") | |
| st.divider() | |
| # ── Emotion Timeline Chart ─────────────────────────────────────── | |
| if timeline and len(timeline) > 1: | |
| st.markdown("### Emotion Timeline") | |
| _render_timeline_chart(timeline) | |
| st.divider() | |
| # ── Emotion Distribution ───────────────────────────────────────── | |
| col_dist, col_mod = st.columns(2) | |
| with col_dist: | |
| st.markdown("### Emotion Distribution") | |
| _render_distribution(summary.emotion_distribution) | |
| with col_mod: | |
| st.markdown("### Modality Contribution") | |
| _render_modality_contribution(summary.modality_contribution) | |
| st.divider() | |
| # ── Emotional Shifts & Peaks ───────────────────────────────────── | |
| col_shift, col_peak = st.columns(2) | |
| with col_shift: | |
| st.markdown("### Emotional Shifts") | |
| if summary.emotional_shifts: | |
| for shift in summary.emotional_shifts[:10]: | |
| t = shift["timestamp"] | |
| from_emo = shift["from"] | |
| to_emo = shift["to"] | |
| from_emoji = EMOTION_EMOJI.get(EmotionLabel(from_emo), "") | |
| to_emoji = EMOTION_EMOJI.get(EmotionLabel(to_emo), "") | |
| snippet = shift.get("transcript", "") | |
| st.markdown( | |
| '<div class="glass-card" style="padding: 10px; margin: 4px 0;">' | |
| '<span style="color: #6B7B9D; font-size: 12px;">{:.0f}s</span> ' | |
| '<span style="font-size: 18px;">{}</span> ' | |
| '<span style="color: #6B7B9D; font-weight: 700;">{}</span>' | |
| ' → ' | |
| '<span style="font-size: 18px;">{}</span> ' | |
| '<span style="color: #00D4FF; font-weight: 700;">{}</span>' | |
| '{}' | |
| '</div>'.format( | |
| t, from_emoji, from_emo, to_emoji, to_emo, | |
| f'<br/><span style="color: #6B7B9D; font-size: 11px;">"{snippet}"</span>' | |
| if snippet else "", | |
| ), | |
| unsafe_allow_html=True, | |
| ) | |
| else: | |
| st.markdown( | |
| '<p style="color: #6B7B9D;">No significant emotional shifts detected.</p>', | |
| unsafe_allow_html=True, | |
| ) | |
| with col_peak: | |
| st.markdown("### Emotional Peaks") | |
| if summary.peaks: | |
| for peak in summary.peaks[:10]: | |
| t = peak["timestamp"] | |
| emo = peak["emotion"] | |
| score = peak["score"] | |
| emoji = EMOTION_EMOJI.get(EmotionLabel(emo), "") | |
| color = EMOTION_COLORS.get(EmotionLabel(emo), "#94A3B8") | |
| snippet = peak.get("transcript", "") | |
| st.markdown( | |
| '<div class="glass-card" style="padding: 10px; margin: 4px 0;">' | |
| '<span style="color: #6B7B9D; font-size: 12px;">{:.0f}s</span> ' | |
| '<span style="font-size: 18px;">{}</span> ' | |
| '<span style="color: {}; font-weight: 700;">{}</span> ' | |
| '<span style="color: #B0BCD0;">{:.0f}%</span>' | |
| '{}' | |
| '</div>'.format( | |
| t, emoji, color, emo, score * 100, | |
| f'<br/><span style="color: #6B7B9D; font-size: 11px;">"{snippet}"</span>' | |
| if snippet else "", | |
| ), | |
| unsafe_allow_html=True, | |
| ) | |
| else: | |
| st.markdown( | |
| '<p style="color: #6B7B9D;">No significant peaks detected.</p>', | |
| unsafe_allow_html=True, | |
| ) | |
| st.divider() | |
| # ── Topics & Triggers ──────────────────────────────────────────── | |
| if summary.topics_detected: | |
| st.markdown("### Topics & Triggers Detected") | |
| topic_html = " ".join( | |
| '<span style="display: inline-block; background: rgba(0,212,255,0.15); ' | |
| 'border: 1px solid rgba(0,212,255,0.3); border-radius: 20px; ' | |
| 'padding: 4px 14px; margin: 4px; font-size: 13px; color: #00D4FF; ' | |
| 'font-weight: 600;">{}</span>'.format(t.replace("_", " ").title()) | |
| for t in summary.topics_detected | |
| ) | |
| st.markdown( | |
| '<div class="glass-card">{}</div>'.format(topic_html), | |
| unsafe_allow_html=True, | |
| ) | |
| st.divider() | |
| # ── Transcript ─────────────────────────────────────────────────── | |
| if transcript: | |
| st.markdown("### Session Transcript") | |
| _render_transcript(transcript) | |
| def _render_timeline_chart(timeline: list[TimelineEntry]): | |
| """Render emotion timeline using Plotly.""" | |
| try: | |
| import plotly.graph_objects as go | |
| timestamps = [e.timestamp for e in timeline] | |
| fig = go.Figure() | |
| for label in EMOTION_LABELS: | |
| values = [] | |
| for entry in timeline: | |
| score_map = {s.label: s.score for s in entry.fused_result.scores} | |
| values.append(score_map.get(label, 0.0)) | |
| color = EMOTION_COLORS.get(label, "#94A3B8") | |
| emoji = EMOTION_EMOJI.get(label, "") | |
| fig.add_trace(go.Scatter( | |
| x=timestamps, | |
| y=values, | |
| mode="lines", | |
| name=f"{emoji} {label.value}", | |
| line=dict(color=color, width=2), | |
| fill="none", | |
| hovertemplate="%{y:.1%}<extra>%{fullData.name}</extra>", | |
| )) | |
| fig.update_layout( | |
| plot_bgcolor="rgba(10,10,26,0.3)", | |
| paper_bgcolor="rgba(0,0,0,0)", | |
| font=dict(color="#B0BCD0"), | |
| xaxis=dict( | |
| title="Time (seconds)", | |
| gridcolor="rgba(255,255,255,0.05)", | |
| color="#6B7B9D", | |
| ), | |
| yaxis=dict( | |
| title="Score", | |
| gridcolor="rgba(255,255,255,0.05)", | |
| range=[0, 1], | |
| color="#6B7B9D", | |
| ), | |
| legend=dict( | |
| bgcolor="rgba(10,10,26,0.5)", | |
| bordercolor="rgba(255,255,255,0.1)", | |
| borderwidth=1, | |
| ), | |
| height=350, | |
| margin=dict(l=40, r=20, t=10, b=40), | |
| ) | |
| st.plotly_chart(fig, use_container_width=True) | |
| except ImportError: | |
| # Fallback: render as HTML bars if plotly not available | |
| _render_timeline_html(timeline) | |
| def _render_timeline_html(timeline: list[TimelineEntry]): | |
| """Fallback timeline rendering using pure HTML/CSS.""" | |
| st.markdown( | |
| '<div class="glass-card" style="overflow-x: auto;">', | |
| unsafe_allow_html=True, | |
| ) | |
| for entry in timeline[-20:]: # Last 20 entries | |
| dom = entry.fused_result.dominant | |
| score = entry.fused_result.dominant_score | |
| color = EMOTION_COLORS.get(dom, "#94A3B8") | |
| emoji = EMOTION_EMOJI.get(dom, "") | |
| pct = max(score * 100, 5) | |
| st.markdown( | |
| '<div style="display: flex; align-items: center; margin: 2px 0; font-size: 12px;">' | |
| '<span style="color: #6B7B9D; width: 40px; flex-shrink: 0;">{:.0f}s</span>' | |
| '<div style="flex: 1; background: rgba(255,255,255,0.05); border-radius: 4px; height: 20px; overflow: hidden;">' | |
| '<div style="width: {:.0f}%; height: 100%; background: {}; border-radius: 4px; ' | |
| 'display: flex; align-items: center; padding-left: 6px; color: white; font-weight: 600;">' | |
| '{} {}' | |
| '</div></div></div>'.format(entry.timestamp, pct, color, emoji, dom.value), | |
| unsafe_allow_html=True, | |
| ) | |
| st.markdown('</div>', unsafe_allow_html=True) | |
| def _render_distribution(emotion_dist: dict[str, float]): | |
| """Render emotion distribution as horizontal bars.""" | |
| sorted_items = sorted(emotion_dist.items(), key=lambda x: -x[1]) | |
| for emo_val, score in sorted_items: | |
| try: | |
| label = EmotionLabel(emo_val) | |
| except ValueError: | |
| continue | |
| color = EMOTION_COLORS.get(label, "#94A3B8") | |
| emoji = EMOTION_EMOJI.get(label, "") | |
| pct = max(score * 100, 2) | |
| st.markdown( | |
| '<div class="emotion-bar">' | |
| '<div class="emotion-fill" style="width: {:.0f}%; background: {};">' | |
| '{} {} - {:.1f}%' | |
| '</div></div>'.format(pct, color, emoji, emo_val, score * 100), | |
| unsafe_allow_html=True, | |
| ) | |
| def _render_modality_contribution(modality_contrib: dict[str, float]): | |
| """Render modality weight breakdown.""" | |
| mod_icons = { | |
| "face": "📷", | |
| "voice": "🎤", | |
| "text": "💬", | |
| "posture": "🧙", | |
| "posture/gesture": "🧙", | |
| } | |
| mod_colors = { | |
| "face": "#E948A0", | |
| "voice": "#FFD700", | |
| "text": "#00D4FF", | |
| "posture": "#10B981", | |
| "posture/gesture": "#10B981", | |
| } | |
| # Display-friendly names | |
| mod_display = { | |
| "face": "Face", | |
| "voice": "Voice", | |
| "text": "Speech", | |
| "posture": "Posture/Gesture", | |
| "posture/gesture": "Posture/Gesture", | |
| } | |
| if not modality_contrib: | |
| st.markdown( | |
| '<p style="color: #6B7B9D;">No modality data available.</p>', | |
| unsafe_allow_html=True, | |
| ) | |
| return | |
| for mod, weight in sorted(modality_contrib.items(), key=lambda x: -x[1]): | |
| icon = mod_icons.get(mod, "🔮") | |
| color = mod_colors.get(mod, "#94A3B8") | |
| pct = weight * 100 | |
| st.markdown( | |
| '<div style="margin: 6px 0;">' | |
| '<div style="display: flex; align-items: center; margin-bottom: 2px;">' | |
| '<span style="font-size: 18px;">{}</span>' | |
| '<span style="color: #B0BCD0; font-weight: 600; margin-left: 8px; text-transform: capitalize;">{}</span>' | |
| '<span style="color: {}; margin-left: auto; font-weight: 700;">{:.1f}%</span>' | |
| '</div>' | |
| '<div style="background: rgba(255,255,255,0.05); border-radius: 6px; height: 8px; overflow: hidden;">' | |
| '<div style="width: {:.0f}%; height: 100%; background: {}; border-radius: 6px;"></div>' | |
| '</div></div>'.format(icon, mod_display.get(mod, mod.title()), color, pct, pct, color), | |
| unsafe_allow_html=True, | |
| ) | |
| def _render_transcript(transcript: list[TranscriptSegment]): | |
| """Render transcript with emotional annotations.""" | |
| if not transcript: | |
| st.markdown( | |
| '<p style="color: #6B7B9D;">No transcript data available.</p>', | |
| unsafe_allow_html=True, | |
| ) | |
| return | |
| html_parts = ['<div class="glass-card" style="max-height: 400px; overflow-y: auto;">'] | |
| for seg in transcript: | |
| emoji = "" | |
| color = "#B0BCD0" | |
| if seg.emotion: | |
| emoji = EMOTION_EMOJI.get(seg.emotion, "") | |
| color = EMOTION_COLORS.get(seg.emotion, "#B0BCD0") | |
| mins = int(seg.timestamp) // 60 | |
| secs = int(seg.timestamp) % 60 | |
| html_parts.append( | |
| '<div style="padding: 8px 0; border-bottom: 1px solid rgba(255,255,255,0.05);">' | |
| '<span style="color: #6B7B9D; font-size: 11px; font-weight: 600;">' | |
| '{}:{:02d}</span> ' | |
| '<span style="font-size: 16px;">{}</span> ' | |
| '<span style="color: {};">{}</span>' | |
| '</div>'.format(mins, secs, emoji, color, seg.text) | |
| ) | |
| html_parts.append('</div>') | |
| st.markdown("".join(html_parts), unsafe_allow_html=True) | |