Spaces:
Running
Running
Update app.py - custom webcam component, no WebRTC
Browse files
app.py
CHANGED
|
@@ -36,12 +36,22 @@ try:
|
|
| 36 |
except ImportError:
|
| 37 |
HAS_PIL = False
|
| 38 |
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
|
| 46 |
from models import EmotionLabel, EMOTION_LABELS, CulturalRegion, EMOTION_EMOJI
|
| 47 |
from face_detector import FaceEmotionDetector
|
|
@@ -657,10 +667,7 @@ def show_demo():
|
|
| 657 |
return
|
| 658 |
|
| 659 |
# ── Primary: Live Streaming ──────────────────────────────────────
|
| 660 |
-
|
| 661 |
-
_show_live_session(processor, remaining, start)
|
| 662 |
-
else:
|
| 663 |
-
st.warning("Live streaming requires streamlit-webrtc. Use video upload below.")
|
| 664 |
|
| 665 |
# ── Secondary: Video Upload ──────────────────────────────────────
|
| 666 |
st.divider()
|
|
@@ -696,97 +703,54 @@ def show_demo():
|
|
| 696 |
|
| 697 |
|
| 698 |
def _show_live_session(processor, remaining, start):
|
| 699 |
-
"""Live session using
|
| 700 |
|
| 701 |
-
#
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
with col_stop:
|
| 705 |
-
if processor.is_active:
|
| 706 |
-
if st.button("⏹ Stop Session", type="primary", use_container_width=True):
|
| 707 |
-
processor.stop_session()
|
| 708 |
-
st.session_state.show_report = True
|
| 709 |
-
st.rerun()
|
| 710 |
|
| 711 |
-
with
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
|
| 715 |
-
|
| 716 |
-
|
| 717 |
-
|
| 718 |
-
|
| 719 |
-
|
| 720 |
-
|
| 721 |
-
|
| 722 |
-
|
| 723 |
-
|
| 724 |
-
|
| 725 |
-
|
| 726 |
-
|
| 727 |
-
|
| 728 |
-
|
| 729 |
-
|
| 730 |
-
|
| 731 |
-
|
| 732 |
-
|
| 733 |
-
|
| 734 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 735 |
st.session_state.show_report = True
|
| 736 |
st.rerun()
|
| 737 |
|
| 738 |
-
|
| 739 |
-
|
| 740 |
-
|
| 741 |
-
with col_video:
|
| 742 |
-
# The WebRTC component provides its own START/STOP button
|
| 743 |
-
webrtc_ctx = webrtc_streamer(
|
| 744 |
-
key="emosphere-live",
|
| 745 |
-
mode=WebRtcMode.SENDRECV,
|
| 746 |
-
video_frame_callback=processor.video_frame_callback,
|
| 747 |
-
audio_frame_callback=processor.audio_frame_callback,
|
| 748 |
-
media_stream_constraints={
|
| 749 |
-
"video": {"width": {"ideal": 640}, "height": {"ideal": 480}},
|
| 750 |
-
"audio": True,
|
| 751 |
-
},
|
| 752 |
-
rtc_configuration={
|
| 753 |
-
"iceServers": [
|
| 754 |
-
{"urls": ["stun:stun.l.google.com:19302"]},
|
| 755 |
-
{"urls": ["stun:stun1.l.google.com:19302"]},
|
| 756 |
-
{"urls": ["stun:stun2.l.google.com:19302"]},
|
| 757 |
-
{"urls": ["stun:stun3.l.google.com:19302"]},
|
| 758 |
-
{"urls": ["stun:stun4.l.google.com:19302"]},
|
| 759 |
-
{
|
| 760 |
-
"urls": [
|
| 761 |
-
"turn:openrelay.metered.ca:80",
|
| 762 |
-
"turn:openrelay.metered.ca:80?transport=tcp",
|
| 763 |
-
"turn:openrelay.metered.ca:443",
|
| 764 |
-
"turns:openrelay.metered.ca:443",
|
| 765 |
-
],
|
| 766 |
-
"username": "openrelayproject",
|
| 767 |
-
"credential": "openrelayproject",
|
| 768 |
-
},
|
| 769 |
-
]
|
| 770 |
-
},
|
| 771 |
-
async_processing=True,
|
| 772 |
-
)
|
| 773 |
-
|
| 774 |
-
# Auto-start processing when WebRTC connects
|
| 775 |
-
if webrtc_ctx.state.playing and not processor.is_active:
|
| 776 |
-
processor.start_session()
|
| 777 |
-
st.rerun()
|
| 778 |
-
|
| 779 |
-
# Auto-stop when WebRTC disconnects
|
| 780 |
-
if not webrtc_ctx.state.playing and processor.is_active:
|
| 781 |
-
processor.stop_session()
|
| 782 |
-
st.session_state.show_report = True
|
| 783 |
-
st.rerun()
|
| 784 |
-
|
| 785 |
-
# Auto-stop after 60 seconds of session
|
| 786 |
-
if processor.is_active and processor.elapsed_seconds >= 60:
|
| 787 |
-
processor.stop_session()
|
| 788 |
-
st.session_state.show_report = True
|
| 789 |
-
st.rerun()
|
| 790 |
|
| 791 |
with col_results:
|
| 792 |
if processor.is_active:
|
|
@@ -794,29 +758,25 @@ def _show_live_session(processor, remaining, start):
|
|
| 794 |
else:
|
| 795 |
st.markdown(
|
| 796 |
'<div class="glass-card" style="text-align: center; padding: 40px;">'
|
| 797 |
-
'<span style="font-size: 48px;">
|
| 798 |
'<h3 style="margin: 12px 0 8px; color: #B0BCD0 !important;">Ready to Stream</h3>'
|
| 799 |
'<p style="color: #6B7B9D; margin: 0; font-size: 13px;">'
|
| 800 |
-
'Click
|
| 801 |
-
'your 60-second live emotion analysis
|
| 802 |
'<div style="margin-top: 16px; padding: 12px; background: rgba(0,212,255,0.06); '
|
| 803 |
'border-radius: 8px; border: 1px solid rgba(0,212,255,0.15);">'
|
| 804 |
'<p style="color: #00D4FF; font-size: 12px; margin: 0;">'
|
| 805 |
-
'
|
| 806 |
'All fused with fuzzy logic in real-time.</p>'
|
| 807 |
'</div>'
|
| 808 |
'</div>',
|
| 809 |
unsafe_allow_html=True,
|
| 810 |
)
|
| 811 |
|
| 812 |
-
# Refresh while active to update results
|
| 813 |
-
if processor.is_active:
|
| 814 |
-
_schedule_rerun_fast()
|
| 815 |
-
|
| 816 |
|
| 817 |
-
@st.fragment(run_every=
|
| 818 |
def _render_live_results(processor):
|
| 819 |
-
"""Auto-updating display of live emotion results."""
|
| 820 |
fused = processor.get_latest_fused()
|
| 821 |
face = processor.get_latest_face()
|
| 822 |
voice = processor.get_latest_voice()
|
|
@@ -829,7 +789,7 @@ def _render_live_results(processor):
|
|
| 829 |
if fused is None:
|
| 830 |
st.markdown(
|
| 831 |
'<div class="glass-card" style="text-align: center; padding: 20px;">'
|
| 832 |
-
'<span style="font-size: 36px;">
|
| 833 |
'<p style="color: #6B7B9D; margin-top: 8px;">'
|
| 834 |
'Analyzing... Speak, move, or express yourself.</p>'
|
| 835 |
'</div>',
|
|
@@ -847,10 +807,10 @@ def _render_live_results(processor):
|
|
| 847 |
# Modality signals
|
| 848 |
st.markdown("#### Modality Signals")
|
| 849 |
mod_data = [
|
| 850 |
-
("
|
| 851 |
-
("
|
| 852 |
-
("
|
| 853 |
-
("
|
| 854 |
]
|
| 855 |
mod_colors = ["#E948A0", "#FFD700", "#00D4FF", "#10B981"]
|
| 856 |
|
|
@@ -919,7 +879,7 @@ def _render_live_results(processor):
|
|
| 919 |
# Stats
|
| 920 |
st.markdown(
|
| 921 |
'<div style="color: #6B7B9D; font-size: 11px; margin-top: 8px; text-align: right;">'
|
| 922 |
-
'Frames: {} &
|
| 923 |
'</div>'.format(
|
| 924 |
stats.get("video_frames", 0),
|
| 925 |
stats.get("audio_chunks", 0),
|
|
@@ -929,18 +889,6 @@ def _render_live_results(processor):
|
|
| 929 |
)
|
| 930 |
|
| 931 |
|
| 932 |
-
def _schedule_rerun_fast():
|
| 933 |
-
"""Schedule a fast rerun to keep live results updating."""
|
| 934 |
-
try:
|
| 935 |
-
import streamlit.components.v1 as components
|
| 936 |
-
components.html(
|
| 937 |
-
'<script>setTimeout(function() { window.location.reload(); }, 2000);</script>',
|
| 938 |
-
height=0,
|
| 939 |
-
)
|
| 940 |
-
except Exception:
|
| 941 |
-
pass
|
| 942 |
-
|
| 943 |
-
|
| 944 |
def _show_video_processing(processor, start):
|
| 945 |
"""Process an uploaded video and show results."""
|
| 946 |
video_bytes = st.session_state.get("video_bytes")
|
|
|
|
| 36 |
except ImportError:
|
| 37 |
HAS_PIL = False
|
| 38 |
|
| 39 |
+
import base64
|
| 40 |
+
import streamlit.components.v1 as components_lib
|
| 41 |
+
import os as _os
|
| 42 |
+
|
| 43 |
+
# Custom webcam component (no WebRTC needed)
|
| 44 |
+
_WEBCAM_DIR = _os.path.join(_os.path.dirname(_os.path.abspath(__file__)), "webcam_component")
|
| 45 |
+
_webcam_component_func = None
|
| 46 |
+
if _os.path.isdir(_WEBCAM_DIR):
|
| 47 |
+
_webcam_component_func = components_lib.declare_component("webcam_capture", path=_WEBCAM_DIR)
|
| 48 |
+
|
| 49 |
+
def webcam_capture(key="webcam"):
|
| 50 |
+
"""Custom webcam component — captures video frames + audio, sends to Python."""
|
| 51 |
+
if _webcam_component_func is None:
|
| 52 |
+
st.error("Webcam component not found.")
|
| 53 |
+
return None
|
| 54 |
+
return _webcam_component_func(key=key, default=None)
|
| 55 |
|
| 56 |
from models import EmotionLabel, EMOTION_LABELS, CulturalRegion, EMOTION_EMOJI
|
| 57 |
from face_detector import FaceEmotionDetector
|
|
|
|
| 667 |
return
|
| 668 |
|
| 669 |
# ── Primary: Live Streaming ──────────────────────────────────────
|
| 670 |
+
_show_live_session(processor, remaining, start)
|
|
|
|
|
|
|
|
|
|
| 671 |
|
| 672 |
# ── Secondary: Video Upload ──────────────────────────────────────
|
| 673 |
st.divider()
|
|
|
|
| 703 |
|
| 704 |
|
| 705 |
def _show_live_session(processor, remaining, start):
|
| 706 |
+
"""Live session using custom webcam component (no WebRTC needed)."""
|
| 707 |
|
| 708 |
+
# Video + Results side by side
|
| 709 |
+
col_video, col_results = st.columns([1.3, 1])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 710 |
|
| 711 |
+
with col_video:
|
| 712 |
+
# Custom webcam component with built-in START/STOP + timer
|
| 713 |
+
component_value = webcam_capture(key="webcam_live")
|
| 714 |
+
|
| 715 |
+
# Handle component messages
|
| 716 |
+
if component_value and isinstance(component_value, dict):
|
| 717 |
+
msg_type = component_value.get("type")
|
| 718 |
+
|
| 719 |
+
if msg_type == "started":
|
| 720 |
+
if not processor.is_active:
|
| 721 |
+
processor.start_session()
|
| 722 |
+
|
| 723 |
+
elif msg_type == "frame":
|
| 724 |
+
if not processor.is_active:
|
| 725 |
+
processor.start_session()
|
| 726 |
+
# Decode base64 JPEG and process
|
| 727 |
+
data_url = component_value.get("data", "")
|
| 728 |
+
if "," in data_url:
|
| 729 |
+
try:
|
| 730 |
+
img_b64 = data_url.split(",", 1)[1]
|
| 731 |
+
img_bytes = base64.b64decode(img_b64)
|
| 732 |
+
processor.process_image(img_bytes)
|
| 733 |
+
except Exception as e:
|
| 734 |
+
print(f"[App] Frame decode error: {e}")
|
| 735 |
+
|
| 736 |
+
elif msg_type == "audio":
|
| 737 |
+
data_url = component_value.get("data", "")
|
| 738 |
+
if "," in data_url:
|
| 739 |
+
try:
|
| 740 |
+
audio_b64 = data_url.split(",", 1)[1]
|
| 741 |
+
audio_bytes = base64.b64decode(audio_b64)
|
| 742 |
+
processor.process_audio_bytes(audio_bytes)
|
| 743 |
+
except Exception as e:
|
| 744 |
+
print(f"[App] Audio decode error: {e}")
|
| 745 |
+
|
| 746 |
+
elif msg_type == "stopped":
|
| 747 |
+
if processor.is_active:
|
| 748 |
+
processor.stop_session()
|
| 749 |
st.session_state.show_report = True
|
| 750 |
st.rerun()
|
| 751 |
|
| 752 |
+
elif msg_type == "error":
|
| 753 |
+
st.error("Camera/mic error: " + component_value.get("message", "unknown"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 754 |
|
| 755 |
with col_results:
|
| 756 |
if processor.is_active:
|
|
|
|
| 758 |
else:
|
| 759 |
st.markdown(
|
| 760 |
'<div class="glass-card" style="text-align: center; padding: 40px;">'
|
| 761 |
+
'<span style="font-size: 48px;">🎥</span>'
|
| 762 |
'<h3 style="margin: 12px 0 8px; color: #B0BCD0 !important;">Ready to Stream</h3>'
|
| 763 |
'<p style="color: #6B7B9D; margin: 0; font-size: 13px;">'
|
| 764 |
+
'Click <strong>START SESSION</strong> on the left to begin '
|
| 765 |
+
'your 60-second live emotion analysis.</p>'
|
| 766 |
'<div style="margin-top: 16px; padding: 12px; background: rgba(0,212,255,0.06); '
|
| 767 |
'border-radius: 8px; border: 1px solid rgba(0,212,255,0.15);">'
|
| 768 |
'<p style="color: #00D4FF; font-size: 12px; margin: 0;">'
|
| 769 |
+
'🧑 Face • 🎙 Voice • 💬 Speech • 🧍 Posture<br/>'
|
| 770 |
'All fused with fuzzy logic in real-time.</p>'
|
| 771 |
'</div>'
|
| 772 |
'</div>',
|
| 773 |
unsafe_allow_html=True,
|
| 774 |
)
|
| 775 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 776 |
|
| 777 |
+
@st.fragment(run_every=2.0)
|
| 778 |
def _render_live_results(processor):
|
| 779 |
+
"""Auto-updating display of live emotion results. Refreshes every 2s."""
|
| 780 |
fused = processor.get_latest_fused()
|
| 781 |
face = processor.get_latest_face()
|
| 782 |
voice = processor.get_latest_voice()
|
|
|
|
| 789 |
if fused is None:
|
| 790 |
st.markdown(
|
| 791 |
'<div class="glass-card" style="text-align: center; padding: 20px;">'
|
| 792 |
+
'<span style="font-size: 36px;">🔮</span>'
|
| 793 |
'<p style="color: #6B7B9D; margin-top: 8px;">'
|
| 794 |
'Analyzing... Speak, move, or express yourself.</p>'
|
| 795 |
'</div>',
|
|
|
|
| 807 |
# Modality signals
|
| 808 |
st.markdown("#### Modality Signals")
|
| 809 |
mod_data = [
|
| 810 |
+
("🧑 Face", face),
|
| 811 |
+
("🎙 Voice", voice),
|
| 812 |
+
("💬 Speech", text),
|
| 813 |
+
("🧍 Posture", posture),
|
| 814 |
]
|
| 815 |
mod_colors = ["#E948A0", "#FFD700", "#00D4FF", "#10B981"]
|
| 816 |
|
|
|
|
| 879 |
# Stats
|
| 880 |
st.markdown(
|
| 881 |
'<div style="color: #6B7B9D; font-size: 11px; margin-top: 8px; text-align: right;">'
|
| 882 |
+
'Frames: {} • Audio: {} • Transcript: {}'
|
| 883 |
'</div>'.format(
|
| 884 |
stats.get("video_frames", 0),
|
| 885 |
stats.get("audio_chunks", 0),
|
|
|
|
| 889 |
)
|
| 890 |
|
| 891 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 892 |
def _show_video_processing(processor, start):
|
| 893 |
"""Process an uploaded video and show results."""
|
| 894 |
video_bytes = st.session_state.get("video_bytes")
|