File size: 29,633 Bytes
2d11b00
1b7bc37
 
 
 
 
 
 
 
2d11b00
 
 
 
 
 
1b7bc37
2d11b00
1b7bc37
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2d11b00
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1b7bc37
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2d11b00
 
1b7bc37
 
 
2d11b00
 
 
 
 
 
 
 
 
 
 
 
1b7bc37
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
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(
    """
<style>
    /* --- General Improvements (Dark Mode) --- */
    body {
        font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
        background-color: #0E1117; /* Dark background */
        color: #FAFAFA; /* Light default text */
    }
    .main .block-container {
        padding-top: 2rem;
        padding-bottom: 3rem;
        max-width: 1200px; /* Limit max width for better readability */
        margin: auto;
    }
    
    /* --- Typography Refinements (Dark Mode) --- */
    h1, h2, h3, h4, h5 {
        font-weight: 600; /* Slightly bolder for hierarchy */
        letter-spacing: -0.8px; /* Tighter spacing */
        color: #ECECEC; /* Light gray for headings */
    }
    h1 {
        font-size: 2.4rem;
        margin-bottom: 0.5rem;
        color: #FFFFFF; /* Brighter white for main title */
    }
    h2 {
        font-size: 1.75rem;
        margin-bottom: 1rem; /* More space below H2 */
        border-bottom: 1px solid #31333F; /* Subtle dark separator */
        padding-bottom: 0.5rem;
    }
    h3 {
        font-size: 1.4rem;
        margin-bottom: 0.75rem;
        font-weight: 500;
        color: #A0A0A0; /* Softer light gray */
    }
    h4 {
        font-size: 1.1rem;
        font-weight: 500;
        color: #888888; /* Dimmer gray */
        margin-bottom: 0.5rem;
    }
    h5 {
        font-size: 0.95rem;
        font-weight: 600;
        color: #B0B0B0; /* Medium light gray */
        margin-bottom: 0.3rem;
        text-transform: uppercase; /* Uppercase for subsection titles */
        letter-spacing: 0.5px;
    }

    /* --- Sidebar Styling (Dark Mode) --- */
    .stSidebar {
        background-color: #1E1E1E; /* Dark gray sidebar */
        border-right: 1px solid #31333F; /* Subtle dark border */
    }
    .stSidebar h2 {
        border-bottom: none; /* Remove border for sidebar H2 */
        text-align: center;
        font-size: 1.6rem;
        color: #FAFAFA; /* Light text */
    }
    .stSidebar h3 {
        font-size: 1.1rem;
        color: #00A1E0; /* Brighter blue accent for dark bg */
        margin-top: 1.5rem;
        margin-bottom: 0.5rem;
    }
    .stSidebar .stMarkdown p, .stSidebar .stSlider label, .stSidebar .stSelectbox label {
        color: #C0C0C0; /* Lighter text for sidebar elements */
    }
    
    /* --- Controls and Inputs (Dark Mode) --- */
    .stSlider label, .stSelectbox label {
        font-size: 0.85rem;
        font-weight: 500;
        color: #C0C0C0; /* Light gray labels */
        margin-bottom: 0.2rem;
    }
    .stSlider {
        padding-top: 0.1rem;
        padding-bottom: 0.8rem;
    }
    .stSelectbox > div > div { /* Target selectbox input */
        background-color: #262730;
        border: 1px solid #31333F;
        color: #FAFAFA;
    }
    .stSelectbox svg { /* Target selectbox arrow */
       fill: #FAFAFA;
    }
    .stSelectbox [data-baseweb="select"] > div { /* Ensure dropdown text is light */
        color: #FAFAFA;
    }
    
    /* --- Buttons (Dark Mode) --- */
    div.stButton > button {
        border-radius: 6px; /* Slightly more rounded */
        height: 2.8rem;
        font-weight: 500;
        border: 1px solid #00A1E0; /* Brighter blue border */
        background-color: transparent; /* Transparent background */
        color: #00A1E0; /* Brighter blue text */
        transition: all 0.2s ease-in-out;
    }
    div.stButton > button:hover {
        background-color: rgba(0, 161, 224, 0.1); /* Slight blue tint on hover */
        color: #00C0FF; /* Even brighter blue */
        border-color: #00C0FF;
        box-shadow: none; /* Remove shadow */
    }
    /* Primary button */
    div.stButton > button[kind="primary"] {
         background-color: #00A1E0;
         color: #0E1117; /* Dark text on bright button */
         border: none;
    }
     div.stButton > button[kind="primary"]:hover {
         background-color: #007BAA; /* Darker blue on hover */
         color: #FFFFFF;
         box-shadow: none;
     }

    /* --- Containers & Layout (Dark Mode) --- */
    .stExpander {
        border: 1px solid #31333F; /* Darker border */
        border-radius: 8px; /* More rounded */
        box-shadow: none; /* Remove shadow for flatter look */
        margin-bottom: 1rem;
        background-color: #262730; /* Darker container background */
    }
    .stExpander header {
        font-weight: 500;
        color: #C0C0C0; /* Lighter header text */
    }
    .stExpander p, .stExpander li {
        color: #B0B0B0; /* Light gray text inside expander */
    }
    
    /* --- Tabs Styling (Dark Mode) --- */
    .stTabs [data-baseweb="tab-list"] {
        gap: 5px; /* More gap between tabs */
        border-bottom: 2px solid #31333F; /* Darker bottom border */
    }
    .stTabs [data-baseweb="tab"] {
        height: 2.8rem;
        background-color: #1E1E1E; /* Darker tab background */
        border-radius: 6px 6px 0 0; /* Rounded top corners */
        padding: 10px 15px;
        font-weight: 500;
        color: #888888; /* Dimmer inactive tab color */
        border: 1px solid transparent; /* Prepare for border */
        border-bottom: none;
        transition: background-color 0.2s ease, color 0.2s ease;
    }
    .stTabs [aria-selected="true"] {
        background-color: #262730; /* Slightly lighter background for active tab */
        color: #00A1E0; /* Accent color for active tab */
        border-color: #31333F #31333F #262730; /* Connect border */
        font-weight: 600;
    }

    /* --- Metrics Styling (Dark Mode) --- */
    [data-testid="stMetric"] {
        background-color: #262730; /* Dark background for metrics */
        border: 1px solid #31333F; /* Darker border */
        border-radius: 8px;
        padding: 1rem;
        text-align: center;
    }
    [data-testid="stMetricValue"] {
        font-size: 1.8rem !important; /* Larger metric value */
        font-weight: 600 !important;
        color: #FAFAFA; /* Light value text */
    }
    [data-testid="stMetricLabel"] {
        font-size: 0.8rem !important;
        color: #888888; /* Dimmer label text */
        text-transform: uppercase;
        letter-spacing: 0.5px;
    }
    
    /* --- Chart Container (Dark Mode) --- */
    .chart-container { /* Ensure chart has a dark background */
        background: #262730; /* Dark background for chart */
        border-radius: 8px;
        padding: 1rem;
        border: 1px solid #31333F; /* Darker border */
        margin-top: 1.5rem;
    }
    /* Make Altair chart text light */
    .chart-container .mark-text text {
        fill: #FAFAFA;
    }
    .chart-container .axis-title {
         fill: #C0C0C0;
    }
     .chart-container .axis text {
         fill: #A0A0A0;
    }
     .chart-container .legend-title {
         fill: #C0C0C0;
    }
     .chart-container .legend-label text {
         fill: #A0A0A0;
    }

    
    /* --- Video Container (Dark Mode) --- */
    /* Styles applied directly in Python code might override this */
    .stWebRTC { 
        border-radius: 8px;
        overflow: hidden;
        border: 1px solid #31333F; /* Darker border */
        margin-bottom: 1rem; /* Add some space below video */
    }

    /* --- Clean Dividers (Dark Mode) --- */
    hr {
        border: none;
        border-top: 1px solid #31333F; /* Darker divider */
        margin: 2rem 0;
    }

    /* --- Hide Streamlit Footer --- */
    #MainMenu {visibility: hidden;}
    footer {visibility: hidden;}

</style>
""",
    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("<hr>", unsafe_allow_html=True)

# Setup the sidebar with camera parameters
with st.sidebar:
    st.markdown("## Control Panel")

    # Method selection with modern look
    st.markdown("### Detection Method")
    method_name = st.selectbox(
        "Select algorithm",
        [
            "HoughLinesP",
            "AdaptiveHoughLinesP",
            "RansacLine",
            "FitEllipse",
            "RotatedRect",
        ],
    )

    # Create tabs for different setting categories
    settings_tab, tuning_tab = st.tabs(["Camera Settings", "PID Tuning"])

    with settings_tab:
        # HSV Filter with modern sliders
        st.markdown("#### HSV Filter")

        # Create two columns for min/max values
        col_min, col_max = st.columns(2)

        with col_min:
            st.markdown("##### Min")
            h_min = st.slider("H min", 0, 179, 0)
            s_min = st.slider("S min", 0, 255, 0)
            v_min = st.slider("V min", 0, 255, 0)

        with col_max:
            st.markdown("##### Max")
            h_max = st.slider("H max", 0, 179, 179)
            s_max = st.slider("S max", 0, 255, 255)
            v_max = st.slider("V max", 0, 255, 255)

        # ROI settings
        st.markdown("#### Region of Interest")
        roi_width = st.slider("Width", 50, 640, 320, step=10)
        roi_height = st.slider("Height", 50, 480, 240, step=10)

    with tuning_tab:
        # PID Controller Settings with better organization
        st.markdown("#### Angle Control (Yaw)")

        # PID Parameters for Angle Control
        angle_kp = st.slider(
            "Kp", 0.0, 5.0, 1.0, 0.1, help="Proportional gain for angle control"
        )
        angle_ki = st.slider(
            "Ki", 0.0, 1.0, 0.0, 0.01, help="Integral gain for angle control"
        )
        angle_kd = st.slider(
            "Kd", 0.0, 5.0, 0.5, 0.1, help="Derivative gain for angle control"
        )
        angle_setpoint = st.slider(
            "Setpoint",
            -90.0,
            90.0,
            0.0,
            1.0,
            help="Desired angle in degrees (0° = vertical)",
        )

        st.markdown("#### Position Control (Roll)")

        # PID Parameters for Position Control
        pos_kp = st.slider(
            "Kp",
            0.0,
            2.0,
            0.5,
            0.05,
            help="Proportional gain for position control",
        )
        pos_ki = st.slider(
            "Ki",
            0.0,
            1.0,
            0.0,
            0.01,
            help="Integral gain for position control",
        )
        pos_kd = st.slider(
            "Kd",
            0.0,
            5.0,
            0.2,
            0.05,
            help="Derivative gain for position control",
        )
        pos_setpoint = st.slider(
            "Setpoint",
            -100,
            100,
            0,
            1,
            help="Desired position (0 = center of frame)",
        )

    # Reset PID Controllers Button - outside tabs for easy access
    if st.button("Reset PID Controllers", type="primary"):
        st.session_state.reset_pid = True
    else:
        st.session_state.reset_pid = False

    # Add instructions at the bottom of sidebar
    with st.expander("How to use", expanded=False):
        st.markdown(
            """
        ### Quick Guide
        
        1. **Start camera** stream
        2. **Adjust HSV filters** to isolate the line 
        3. **Set region of interest** for detection
        4. **Choose detection algorithm**
        5. **Tune PID parameters**:
           - Start with Kp only
           - Add Kd to reduce oscillation
           - Add Ki to eliminate steady-state error
        
        [About PID tuning →](https://youtu.be/wkfEZmsQqiA?si=uikKLLS4MLxxTI5m)
        """
        )

# Map method names to actual methods
method_map = {
    "HoughLinesP": HoughLinesP,
    "AdaptiveHoughLinesP": AdaptiveHoughLinesP,
    "RansacLine": RansacLine,
    "FitEllipse": FitEllipse,
    "RotatedRect": RotatedRect,
}

# Initialize session state for HSV values, method, and ROI settings
if "hsv_lower" not in st.session_state:
    st.session_state.hsv_lower = [h_min, s_min, v_min]
    st.session_state.hsv_upper = [h_max, s_max, v_max]
    st.session_state.method = method_name
    st.session_state.roi_width = roi_width
    st.session_state.roi_height = roi_height

# Update session state with current values
st.session_state.hsv_lower = [h_min, s_min, v_min]
st.session_state.hsv_upper = [h_max, s_max, v_max]
st.session_state.method = method_name
st.session_state.roi_width = roi_width
st.session_state.roi_height = roi_height

# Initialize session state for PID outputs
if "yaw_output" not in st.session_state:
    st.session_state.yaw_output = 0.0
    st.session_state.roll_output = 0.0
    st.session_state.p_term_angle = 0.0
    st.session_state.i_term_angle = 0.0
    st.session_state.d_term_angle = 0.0
    st.session_state.p_term_pos = 0.0
    st.session_state.i_term_pos = 0.0
    st.session_state.d_term_pos = 0.0


class VideoTransformer(VideoProcessorBase):
    def __init__(self):
        self.detector = LineDetector(estimation_method=HoughLinesP)
        self.hsv_lower = np.array([0, 0, 0], dtype=np.uint8)
        self.hsv_upper = np.array([179, 255, 255], dtype=np.uint8)
        self.method = HoughLinesP
        self.roi_size = (320, 240)

        # Initialize PID Controllers
        self.angle_pid = PIDController(
            kp=1.0, ki=0.0, kd=0.5, setpoint=0.0, min_output=-100, max_output=100
        )
        self.position_pid = PIDController(
            kp=0.5, ki=0.0, kd=0.2, setpoint=0.0, min_output=-100, max_output=100
        )

        # Frame counter for smoother updates
        self.frame_count = 0
        # Initialize instance variables for PID outputs
        self.yaw_output = 0.0
        self.roll_output = 0.0
        self.p_term_angle = 0.0
        self.i_term_angle = 0.0
        self.d_term_angle = 0.0
        self.p_term_pos = 0.0
        self.i_term_pos = 0.0
        self.d_term_pos = 0.0

    def recv(self, frame: av.VideoFrame) -> av.VideoFrame:
        img = frame.to_ndarray(format="bgr24")

        # Update detector with latest settings
        self.detector.color_detector.hsv_color = np.vstack(
            [self.hsv_lower, self.hsv_upper]
        )
        self.detector.estimation_method = self.method

        # Run detection
        output, roi_mask, cx, ang, conf = self.detector.detect_line(
            img, region=self.roi_size, draw=False
        )

        # Reset PID controllers if requested
        if "reset_pid" in st.session_state and st.session_state.reset_pid:
            self.angle_pid.reset()
            self.position_pid.reset()
            st.session_state.reset_pid = False

        # Update PID controllers with latest settings
        self.angle_pid.kp = st.session_state.get("angle_kp", 1.0)
        self.angle_pid.ki = st.session_state.get("angle_ki", 0.0)
        self.angle_pid.kd = st.session_state.get("angle_kd", 0.5)
        self.angle_pid.setpoint = st.session_state.get("angle_setpoint", 0.0)

        self.position_pid.kp = st.session_state.get("pos_kp", 0.5)
        self.position_pid.ki = st.session_state.get("pos_ki", 0.0)
        self.position_pid.kd = st.session_state.get("pos_kd", 0.2)
        self.position_pid.setpoint = st.session_state.get("pos_setpoint", 0.0)

        # Compute PID outputs based on detected values
        yaw_output = roll_output = 0.0

        if not math.isnan(ang) and not math.isnan(cx):
            # Get image dimensions
            h, w = img.shape[:2]

            # Normalize center position to be relative to center of frame
            # cx is already relative to ROI
            normalized_cx = cx - (w / 2)

            # Calculate PID outputs
            yaw_output, p_angle, i_angle, d_angle = self.angle_pid.compute(ang)
            roll_output, p_pos, i_pos, d_pos = self.position_pid.compute(normalized_cx)

            self.yaw_output = yaw_output
            self.roll_output = roll_output
            self.p_term_angle = p_angle
            self.i_term_angle = i_angle
            self.d_term_angle = d_angle
            self.p_term_pos = p_pos
            self.i_term_pos = i_pos
            self.d_term_pos = d_pos

            self.frame_count += 1
        else:
            self.yaw_output = 0.0
            self.roll_output = 0.0

        # Draw diagnostics with modern minimalist style
        h, w = img.shape[:2]

        # Modern color scheme for all UI elements
        roi_color = (41, 128, 185)  # Blue
        text_bg_color = (52, 73, 94, 200)  # Dark slate with higher opacity
        text_color = (255, 255, 255)  # Pure white for better contrast

        # Create a clean, non-obtrusive design

        # Draw ROI rectangle with modern blue color and thinner line
        cx_mask, cy_mask = w // 2, h // 2
        w_roi, h_roi = self.roi_size
        off_x, off_y = cx_mask - w_roi // 2, cy_mask - h_roi // 2
        top_left = (off_x, off_y)
        bottom_right = (off_x + w_roi, off_y + h_roi)

        # Draw more professional ROI border - thinner and with rounded corners effect
        cv2.rectangle(output, top_left, bottom_right, roi_color, 2)

        # Draw dots at corners for rounded look
        corner_radius = 3
        for corner in [
            top_left,
            (bottom_right[0], top_left[1]),
            (top_left[0], bottom_right[1]),
            bottom_right,
        ]:
            cv2.circle(output, corner, corner_radius, roi_color, -1)

        # Create a cleaner info overlay
        # Bottom right position for less interference with the line
        overlay_height = 90
        overlay_width = 200
        overlay_margin = 15
        overlay_position = (
            w - overlay_width - overlay_margin,
            h - overlay_height - overlay_margin,
        )

        # Create semi-transparent overlay
        overlay = output.copy()
        cv2.rectangle(
            overlay,
            overlay_position,
            (overlay_position[0] + overlay_width, overlay_position[1] + overlay_height),
            text_bg_color[:3],  # OpenCV doesn't support alpha in rectangle
            -1,
        )

        # Apply transparency
        alpha = 0.75
        cv2.addWeighted(overlay, alpha, output, 1 - alpha, 0, output)

        # Add a subtle border
        cv2.rectangle(
            output,
            overlay_position,
            (overlay_position[0] + overlay_width, overlay_position[1] + overlay_height),
            (255, 255, 255, 128),  # White border
            1,
        )

        # Modern font
        font = cv2.FONT_HERSHEY_SIMPLEX
        font_scale = 0.55
        font_thickness = 1
        line_height = 20

        # Start position for text
        text_start_x = overlay_position[0] + 10
        text_start_y = overlay_position[1] + 20

        # Function to draw text with subtle shadow for better readability
        def draw_text_with_shadow(text, pos_y, color=text_color):
            # Shadow effect (subtle)
            cv2.putText(
                output,
                text,
                (text_start_x + 1, pos_y + 1),
                font,
                font_scale,
                (0, 0, 0, 150),
                font_thickness,
            )
            # Main text
            cv2.putText(
                output,
                text,
                (text_start_x, pos_y),
                font,
                font_scale,
                color,
                font_thickness,
            )

        # Draw sensor values with more modern, clean formatting
        draw_text_with_shadow("Line Detection", text_start_y - 5)

        # Add a subtle underline
        cv2.line(
            output,
            (text_start_x, text_start_y + 2),
            (text_start_x + 100, text_start_y + 2),
            (255, 255, 255, 150),
            1,
        )

        if not math.isnan(ang):
            draw_text_with_shadow(f"Angle: {ang:.1f}", text_start_y + line_height)
        else:
            draw_text_with_shadow("Angle: --", text_start_y + line_height)

        if not math.isnan(cx):
            draw_text_with_shadow(f"Position: {cx:.1f}", text_start_y + 2 * line_height)
        else:
            draw_text_with_shadow("Position: --", text_start_y + 2 * line_height)

        # Draw PID outputs with color indication
        yaw_color = (130, 220, 255) if abs(yaw_output) < 50 else (130, 130, 255)
        draw_text_with_shadow(
            f"Control: {yaw_output:.1f}, {roll_output:.1f}",
            text_start_y + 3 * line_height,
            yaw_color,
        )

        # Fetch the intermediate results for preview
        filtered = self.detector.color_detector.result

        # Prepare filtered preview - more compact
        pw, ph = w // 6, h // 6  # Smaller preview size
        filtered_preview = cv2.resize(filtered, (pw, ph))

        # Add a cleaner border to the preview
        filtered_preview = cv2.copyMakeBorder(
            filtered_preview, 1, 1, 1, 1, cv2.BORDER_CONSTANT, value=(255, 255, 255)
        )

        # Position the filter preview in top-right corner more elegantly
        preview_padding = 10
        output[
            preview_padding : preview_padding + ph + 2,
            w - pw - preview_padding - 2 : w - preview_padding,
        ] = filtered_preview

        # Add a small "Filter" label above the preview for clarity
        small_font_scale = 0.4
        cv2.putText(
            output,
            "Filter",
            (w - pw - preview_padding, preview_padding - 4),
            font,
            small_font_scale,
            (255, 255, 255),
            1,
        )

        return av.VideoFrame.from_ndarray(output, format="bgr24")


# Create a simplified layout with two main rows instead of tabs
col1, col2 = st.columns([3, 1], gap="large")

with col1:
    # Video stream (expanded from the tab layout)
    st.markdown("### Line Following Camera")
    # Wrap WebRTC in a div for styling (optional, if needed)
    st.markdown('<div class="stWebRTC">', 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("<br>", 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("<hr>", 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('<div class="chart-container">', unsafe_allow_html=True)
    chart_placeholder.altair_chart(chart, use_container_width=True)
    st.markdown("</div>", unsafe_allow_html=True)

# Hide Streamlit footer/menu
st.markdown(
    """
<style>
#MainMenu {visibility: hidden;}
footer {visibility: hidden;}
</style>
""",
    unsafe_allow_html=True,
)