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