import os
import cv2
import math
import numpy as np
import av
import streamlit as st
import pandas as pd
import altair as alt
import time
from streamlit_webrtc import (
webrtc_streamer,
VideoProcessorBase,
WebRtcMode,
VideoHTMLAttributes,
)
from streamlit_autorefresh import st_autorefresh
from twilio.rest import Client
from line_detector import (
LineDetector,
HoughLinesP,
AdaptiveHoughLinesP,
RansacLine,
RotatedRect,
FitEllipse,
)
from pid_controller import PIDController
# Set page configuration
st.set_page_config(
page_title="Line Follower PID",
page_icon="š",
layout="wide",
initial_sidebar_state="expanded",
)
def get_ice_servers():
"""
Get ICE servers configuration.
For Streamlit Cloud deployment, a TURN server is required in addition to STUN.
This function will try to use Twilio's TURN server service if credentials are available,
otherwise it falls back to a free STUN server from Google.
"""
try:
# Try to get Twilio credentials from environment variables
account_sid = os.environ.get("TWILIO_ACCOUNT_SID")
auth_token = os.environ.get("TWILIO_AUTH_TOKEN")
if account_sid and auth_token:
client = Client(account_sid, auth_token)
token = client.tokens.create()
return token.ice_servers
else:
st.warning(
"Twilio credentials not found. Using free STUN server only, which may not work reliably." # Removed Streamlit Cloud mention for generality
)
except Exception as e:
st.error(f"Error setting up Twilio TURN servers: {e}")
# Fallback to Google's free STUN server
return [{"urls": ["stun:stun.l.google.com:19302"]}]
# Apply custom CSS for a modern minimalist design
st.markdown(
"""
""",
unsafe_allow_html=True,
)
# App header with minimalist design
st.title("š Drone Line Follower")
st.markdown("Vision-based line tracking with real-time PID control")
# Add project description
st.markdown(
"""
This application simulates a drone's line-following behavior using visual information.
It processes an image feed, applies a color filter (HSV) to isolate the line,
approximates the line's position and angle using selectable methods
(like HoughLinesP, RANSAC, etc.), and uses PID controllers to adjust the simulated
drone's angle (yaw) and lateral position (roll) to stay centered on the line.
"""
)
# Add a neat divider
st.markdown("
', unsafe_allow_html=True)
# Create the webrtc component
webrtc_ctx = webrtc_streamer(
key="line-detection",
mode=WebRtcMode.SENDRECV,
rtc_configuration={"iceServers": get_ice_servers()},
video_processor_factory=VideoTransformer,
media_stream_constraints={"video": True, "audio": False},
async_processing=True,
video_html_attrs=VideoHTMLAttributes(
autoPlay=True,
controls=False,
style={
"width": f"1280px",
"height": f"720px",
"border-radius": "8px",
"margin": "0 auto",
"display": "block",
"border": "2px solid #AAAAAA", # Changed border to lighter grey
},
),
)
# Pass the settings to the video transformer
if webrtc_ctx.video_processor:
webrtc_ctx.video_processor.hsv_lower = np.array(
st.session_state.hsv_lower, dtype=np.uint8
)
webrtc_ctx.video_processor.hsv_upper = np.array(
st.session_state.hsv_upper, dtype=np.uint8
)
webrtc_ctx.video_processor.method = method_map[st.session_state.method]
webrtc_ctx.video_processor.roi_size = (
st.session_state.roi_width,
st.session_state.roi_height,
)
# Get the latest PID outputs from the video processor
if webrtc_ctx.state.playing:
st.session_state.yaw_output = webrtc_ctx.video_processor.yaw_output
st.session_state.roll_output = webrtc_ctx.video_processor.roll_output
st.session_state.p_term_angle = webrtc_ctx.video_processor.p_term_angle
st.session_state.i_term_angle = webrtc_ctx.video_processor.i_term_angle
st.session_state.d_term_angle = webrtc_ctx.video_processor.d_term_angle
st.session_state.p_term_pos = webrtc_ctx.video_processor.p_term_pos
st.session_state.i_term_pos = webrtc_ctx.video_processor.i_term_pos
st.session_state.d_term_pos = webrtc_ctx.video_processor.d_term_pos
with col2:
# Simplified metrics section that shows only essential values
st.markdown("### Control Values")
# Display the most important metrics in a clean format
# Use vertical layout for metrics instead of columns
st.metric("Angle Control (Yaw)", f"{st.session_state.get('yaw_output', 0):.1f}")
st.metric(
"Position Control (Roll)", f"{st.session_state.get('roll_output', 0):.1f}"
)
# Add vertical space before button
st.markdown("
", unsafe_allow_html=True)
# Add a reset button for the PID controllers
if st.button("Reset PID Controllers", use_container_width=True, type="primary"):
st.session_state.reset_pid = True
# Create a dedicated row for the PID control graph
st.markdown("
", unsafe_allow_html=True) # Add divider before graph
st.markdown("### PID Controller Output")
chart_placeholder = st.empty()
# Initialize start_time and pid_df exactly once
if "start_time" not in st.session_state:
st.session_state.start_time = time.time()
if "pid_df" not in st.session_state:
# start with a single zero row so the chart axes are set
st.session_state.pid_df = pd.DataFrame([{"time_rel": 0.0, "yaw": 0.0, "roll": 0.0}])
# autoārefresh every 100āÆms
st_autorefresh(interval=500, limit=None, key="pid_refresh")
# On each rerun, if the camera is playing, append the newest PID outputs
if webrtc_ctx.state.playing:
t = time.time() - st.session_state.start_time
new_row = pd.DataFrame(
[
{
"time_rel": t,
"yaw": st.session_state.yaw_output,
"roll": st.session_state.roll_output,
}
]
)
st.session_state.pid_df = pd.concat(
[st.session_state.pid_df, new_row], ignore_index=True
)
# keep only last 100 points
if len(st.session_state.pid_df) > 100:
st.session_state.pid_df = st.session_state.pid_df.iloc[-100:].reset_index(
drop=True
)
# Build an Altair āfoldedā chart so you can see both yaw and roll
# 1) grab the wideāform DataFrame
df = st.session_state.pid_df
# 2) melt it into longāform
df_long = df.melt(
id_vars=["time_rel"],
value_vars=["yaw", "roll"],
var_name="Signal",
value_name="Value",
)
# 3) build your Altair chart off of df_long
chart = (
alt.Chart(df_long)
.mark_line(point=False) # Use point=False for cleaner lines
.encode(
x=alt.X("time_rel:Q", title="Time (s)"),
y=alt.Y("Value:Q", title="Controller Output Value"),
color=alt.Color(
"Signal:N",
title="Control Signal",
scale=alt.Scale(domain=["yaw", "roll"], range=["#007bff", "#ff7f0e"]),
), # Custom colors
tooltip=[
alt.Tooltip("time_rel", title="Time (s)", format=".2f"),
alt.Tooltip("Signal", title="Control Signal"),
alt.Tooltip("Value", title="Output Value", format=".2f"),
],
)
.properties(height=350) # Slightly taller chart
.interactive() # Enable zooming and panning
)
# 4) draw it inside a container for styling
with st.container():
st.markdown('
', unsafe_allow_html=True)
chart_placeholder.altair_chart(chart, use_container_width=True)
st.markdown("
", unsafe_allow_html=True)
# Hide Streamlit footer/menu
st.markdown(
"""
""",
unsafe_allow_html=True,
)