"""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( '
' '

Session Report

' '

Multimodal emotion analysis summary

' '
', 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( '
' '{:.0f}s ' '{} ' '{}' ' → ' '{} ' '{}' '{}' '
'.format( t, from_emoji, from_emo, to_emoji, to_emo, f'
"{snippet}"' if snippet else "", ), unsafe_allow_html=True, ) else: st.markdown( '

No significant emotional shifts detected.

', 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( '
' '{:.0f}s ' '{} ' '{} ' '{:.0f}%' '{}' '
'.format( t, emoji, color, emo, score * 100, f'
"{snippet}"' if snippet else "", ), unsafe_allow_html=True, ) else: st.markdown( '

No significant peaks detected.

', unsafe_allow_html=True, ) st.divider() # ── Topics & Triggers ──────────────────────────────────────────── if summary.topics_detected: st.markdown("### Topics & Triggers Detected") topic_html = " ".join( '{}'.format(t.replace("_", " ").title()) for t in summary.topics_detected ) st.markdown( '
{}
'.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%}%{fullData.name}", )) 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( '
', 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( '
' '{:.0f}s' '
' '
' '{} {}' '
'.format(entry.timestamp, pct, color, emoji, dom.value), unsafe_allow_html=True, ) st.markdown('
', 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( '
' '
' '{} {} - {:.1f}%' '
'.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( '

No modality data available.

', 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( '
' '
' '{}' '{}' '{:.1f}%' '
' '
' '
' '
'.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( '

No transcript data available.

', unsafe_allow_html=True, ) return html_parts = ['
'] 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( '
' '' '{}:{:02d} ' '{} ' '{}' '
'.format(mins, secs, emoji, color, seg.text) ) html_parts.append('
') st.markdown("".join(html_parts), unsafe_allow_html=True)