EmoSphere / session_report.py
chariscait's picture
Update modality display names for posture/gesture
0fe46b3 verified
"""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>'
' &rarr; '
'<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": "&#128247;",
"voice": "&#127908;",
"text": "&#128172;",
"posture": "&#129497;",
"posture/gesture": "&#129497;",
}
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, "&#128302;")
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)