C#로 구현하는 ONVIF RTSP 비디오 스트리밍 및 PTZ 제어

1. ONVIF 프로토콜 이해와 구현

1.1 ONVIF 프로토콜의 개요

ONVIF(Open Network Video Interface Forum)은 네트워크 비디오 장비 간의 상호 운용성을 위한 국제 표준 프로토콜입니다. 2008년 설립 이후, 이 프로토콜은安防监控系统, 원격 모니터링, 스마트 시티 인프라 등 다양한 분야에서 광범위하게 활용되고 있습니다.

ONVIF의 핵심 기능은 다음과 같습니다:

  • 장비 자동 발견(Device Discovery)
  • 실시간 비디오 스트림 접근
  • PTZ(Pan/Tilt/Zoom) 제어
  • 사용자 권한 관리
  • 이벤트 알림 시스템

1.2 C#으로 ONVIF 클라이언트 개발하기

C#에서 ONVIF 클라이언트를 구현하기 위해서는 SOAP/XML 기반의 통신을 처리해야 합니다. 다음 예제는 네트워크상의 ONVIF 호환 장비를 검색하는 기본 구현입니다:

using System;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;

namespace ONVIFCameraClient
{
    public class DeviceDiscovery
    {
        private readonly HttpClient _httpClient;
        
        public DeviceDiscovery()
        {
            _httpClient = new HttpClient();
            _httpClient.Timeout = TimeSpan.FromSeconds(10);
        }

        public async Task<string> SearchDeviceAsync(string ipAddress)
        {
            var serviceUrl = $"http://{ipAddress}/onvif/device_service";
            var soapRequest = BuildDiscoveryRequest();

            var content = new StringContent(
                soapRequest, 
                Encoding.UTF8, 
                "text/xml"
            );

            try
            {
                var response = await _httpClient.PostAsync(serviceUrl, content);
                return await response.Content.ReadAsStringAsync();
            }
            catch (HttpRequestException ex)
            {
                Console.WriteLine($"연결 오류: {ex.Message}");
                return string.Empty;
            }
        }

        private string BuildDiscoveryRequest()
        {
            return @"
                
                    
                        <GetCapabilities xmlns=""http://www.onvif.org/ver10/device/wsdl"">
                            <Category>All</Category>
                        </GetCapabilities>
                    
                ";
        }
    }
}

1.3 세션 관리 및 오류 처리

장비와의 통신에서 안정적인 세션 관리는 필수적입니다. 다음과 같은 예외 처리 패턴을 적용하세요:

public class ONVIFSession : IDisposable
{
    private HttpClient _client;
    private string _sessionId;
    private bool _disposed;

    public async Task<bool> ConnectAsync(string url, string user, string pass)
    {
        try
        {
            _client = new HttpClient { BaseAddress = new Uri(url) };
            var authResponse = await AuthenticateAsync(user, pass);
            _sessionId = ExtractSessionId(authResponse);
            return !string.IsNullOrEmpty(_sessionId);
        }
        catch (Exception ex)
        {
            Console.WriteLine($"세션 연결 실패: {ex.Message}");
            return false;
        }
    }

    public async Task<string> SendCommandAsync(string soapBody)
    {
        if (string.IsNullOrEmpty(_sessionId))
            throw new InvalidOperationException("세션이 연결되지 않았습니다");

        try
        {
            // 명령 전송 로직
            return await ExecuteRequestAsync(soapBody);
        }
        catch (TaskCanceledException)
        {
            // 타임아웃 처리
            throw new TimeoutException("요청 시간이 초과되었습니다");
        }
    }

    public void Dispose()
    {
        if (!_disposed)
        {
            _client?.Dispose();
            _disposed = true;
        }
    }
}

2. RTSP 프로토콜의 구조와 구현

2.1 RTSP 프로토콜 기초

RTSP(Real Time Streaming Protocol)는 스트리밍 미디어 서버를 제어하기 위한 네트워크 프로토콜입니다. TCP 또는 UDP(포트 554)를 통해 작동하며, RTP(Real-time Transport Protocol)와 함께 사용되어 실시간 미디어 전송을 실현합니다.

2.2 RTSP 명령어 구현

RTSP는 다양한 제어 명령을 제공합니다:

using System;
using System.Net.Sockets;
using System.Text;

namespace RTSPClient
{
    public class RtspProtocolHandler
    {
        private TcpClient _connection;
        private NetworkStream _stream;
        private int _sequenceNumber = 1;
        private string _sessionIdentifier;

        public void Connect(string host, int port)
        {
            _connection = new TcpClient(host, port);
            _stream = _connection.GetStream();
        }

        public string SendDescribe(string url)
        {
            var request = $"DESCRIBE {url} RTSP/1.0\r\n" +
                         $"CSeq: {_sequenceNumber++}\r\n" +
                         $"Accept: application/sdp\r\n" +
                         $"\r\n";
            
            return SendRequest(request);
        }

        public string SendSetup(string url, int trackId)
        {
            var request = $"SETUP {url}/trackID={trackId} RTSP/1.0\r\n" +
                         $"CSeq: {_sequenceNumber++}\r\n" +
                         $"Transport: RTP/AVP/TCP;unicast;interleaved=0-1\r\n" +
                         $"\r\n";

            var response = SendRequest(request);
            ParseSession(response);
            return response;
        }

        public string SendPlay(string url, double startTime = 0)
        {
            var request = $"PLAY {url} RTSP/1.0\r\n" +
                         $"CSeq: {_sequenceNumber++}\r\n" +
                         $"Range: npt={startTime}-\r\n" +
                         $"Session: {_sessionIdentifier}\r\n" +
                         $"\r\n";

            return SendRequest(request);
        }

        public string SendPause(string url)
        {
            var request = $"PAUSE {url} RTSP/1.0\r\n" +
                         $"CSeq: {_sequenceNumber++}\r\n" +
                         $"Session: {_sessionIdentifier}\r\n" +
                         $"\r\n";

            return SendRequest(request);
        }

        private string SendRequest(string request)
        {
            byte[] data = Encoding.ASCII.GetBytes(request);
            _stream.Write(data, 0, data.Length);
            
            byte[] buffer = new byte[4096];
            int bytesRead = _stream.Read(buffer, 0, buffer.Length);
            return Encoding.ASCII.GetString(buffer, 0, bytesRead);
        }

        private void ParseSession(string response)
        {
            var lines = response.Split('\n');
            foreach (var line in lines)
            {
                if (line.StartsWith("Session:"))
                {
                    _sessionIdentifier = line.Split(':')[1].Trim().Split(';')[0];
                }
            }
        }
    }
}

3. VLC 플레이어 연동

3.1 .NET에서 VLC 라이브러리 사용

VideoLAN Client(VLC)는 다양한 코덱을 지원하는 강력한 미디어 플레이어입니다. .NET 애플리케이션에서 VLC를 사용하려면 P/Invoke를 통해 네이티브 라이브러리를 호출해야 합니다:

using System;
using System.Runtime.InteropServices;

namespace VideoPlayer
{
    public class VlcMediaPlayer : IDisposable
    {
        private IntPtr _instance;
        private IntPtr _media;
        private IntPtr _player;
        private bool _disposed = false;

        [DllImport("libvlc.dll", CallingConvention = CallingConvention.Cdecl)]
        private static extern IntPtr libvlc_new(int argc, IntPtr[] argv);

        [DllImport("libvlc.dll", CallingConvention = CallingConvention.Cdecl)]
        private static extern IntPtr libvlc_media_new_path(IntPtr instance, string path);

        [DllImport("libvlc.dll", CallingConvention = CallingConvention.Cdecl)]
        private static extern IntPtr libvlc_media_player_new_from_media(IntPtr media);

        [DllImport("libvlc.dll", CallingConvention = CallingConvention.Cdecl)]
        private static extern void libvlc_media_player_play(IntPtr player);

        [DllImport("libvlc.dll", CallingConvention = CallingConvention.Cdecl)]
        private static extern void libvlc_media_player_pause(IntPtr player);

        [DllImport("libvlc.dll", CallingConvention = CallingConvention.Cdecl)]
        private static extern void libvlc_media_player_stop(IntPtr player);

        [DllImport("libvlc.dll", CallingConvention = CallingConvention.Cdecl)]
        private static extern void libvlc_media_player_release(IntPtr player);

        [DllImport("libvlc.dll", CallingConvention = CallingConvention.Cdecl)]
        private static extern void libvlc_media_release(IntPtr media);

        [DllImport("libvlc.dll", CallingConvention = CallingConvention.Cdecl)]
        private static extern void libvlc_release(IntPtr instance);

        public bool Initialize(string vlcPath)
        {
            try
            {
                string[] arguments = {
                    "--no-video-title-show",
                    "--no-snapshot-preview"
                };
                
                IntPtr[] argv = new IntPtr[arguments.Length];
                for (int i = 0; i < arguments.Length; i++)
                {
                    argv[i] = Marshal.StringToHGlobalAnsi(arguments[i]);
                }

                _instance = libvlc_new(arguments.Length, argv);
                
                foreach (var arg in argv)
                {
                    Marshal.FreeHGlobal(arg);
                }

                return _instance != IntPtr.Zero;
            }
            catch (Exception ex)
            {
                Console.WriteLine($"VLC 초기화 실패: {ex.Message}");
                return false;
            }
        }

        public void LoadMedia(string filePath)
        {
            if (_instance == IntPtr.Zero)
                throw new InvalidOperationException("VLC 인스턴스가 초기화되지 않았습니다");

            _media = libvlc_media_new_path(_instance, filePath);
            _player = libvlc_media_player_new_from_media(_media);
        }

        public void Play()
        {
            if (_player != IntPtr.Zero)
                libvlc_media_player_play(_player);
        }

        public void Pause()
        {
            if (_player != IntPtr.Zero)
                libvlc_media_player_pause(_player);
        }

        public void Stop()
        {
            if (_player != IntPtr.Zero)
                libvlc_media_player_stop(_player);
        }

        public void Dispose()
        {
            if (!_disposed)
            {
                if (_player != IntPtr.Zero)
                {
                    libvlc_media_player_release(_player);
                    _player = IntPtr.Zero;
                }

                if (_media != IntPtr.Zero)
                {
                    libvlc_media_release(_media);
                    _media = IntPtr.Zero;
                }

                if (_instance != IntPtr.Zero)
                {
                    libvlc_release(_instance);
                    _instance = IntPtr.Zero;
                }

                _disposed = true;
            }
        }
    }
}

4. 비디오 스트림 재생 구현

4.1 RTSP 스트림을 VLC로 전송

네트워크 카메라로부터 수신한 RTSP 스트림을 VLC 플레이어에 연결하여 재생하는 구조입니다:

using System;

namespace CameraStream
{
    public class StreamReceiver
    {
        private RTSPClient.RtspProtocolHandler _rtspClient;
        private VideoPlayer.VlcMediaPlayer _player;
        private bool _isStreaming;

        public void Initialize(string cameraIp, string vlcPath)
        {
            _rtspClient = new RTSPClient.RtspProtocolHandler();
            _rtspClient.Connect(cameraIp, 554);

            _player = new VideoPlayer.VlcMediaPlayer();
            if (!_player.Initialize(vlcPath))
            {
                throw new Exception("VLC 플레이어 초기화 실패");
            }
        }

        public void StartStreaming(string streamUrl)
        {
            if (_isStreaming) return;

            // RTSP 세션 설정
            _rtspClient.SendDescribe(streamUrl);
            _rtspClient.SendSetup(streamUrl, 1);
            _rtspClient.SendPlay(streamUrl);

            // VLC에 RTSP URL 로드
            _player.LoadMedia(streamUrl);
            _player.Play();
            
            _isStreaming = true;
        }

        public void StopStreaming()
        {
            if (!_isStreaming) return;

            _player.Stop();
            _isStreaming = false;
        }
    }
}

4.2 PTZ 제어 구현

ONVIF 프로토콜을 활용한 PTZ 카메라 제어를 위한 구현 예제입니다:

using System;
using System.Threading.Tasks;

namespace PTZControl
{
    public class PtzController
    {
        private readonly ONVIFCameraClient.ONVIFSession _session;

        public PtzController(ONVIFCameraClient.ONVIFSession session)
        {
            _session = session;
        }

        public async Task MoveUpAsync()
        {
            await ExecutePtzCommand("ContinuousMove", "PanTilt", 0, 0.5);
        }

        public async Task MoveDownAsync()
        {
            await ExecutePtzCommand("ContinuousMove", "PanTilt", 0, -0.5);
        }

        public async Task MoveLeftAsync()
        {
            await ExecutePtzCommand("ContinuousMove", "PanTilt", -0.5, 0);
        }

        public async Task MoveRightAsync()
        {
            await ExecutePtzCommand("ContinuousMove", "PanTilt", 0.5, 0);
        }

        public async Task ZoomInAsync()
        {
            await ExecutePtzCommand("ContinuousMove", "Zoom", 0, 0, 0.5);
        }

        public async Task ZoomOutAsync()
        {
            await ExecutePtzCommand("ContinuousMove", "Zoom", 0, 0, -0.5);
        }

        public async Task StopMovementAsync()
        {
            var soapBody = @"
                <Stop xmlns=""http://www.onvif.org/ver20/ptz/wsdl"">
                    <ProfileToken>MainProfile</ProfileToken>
                    <PanTilt>true</PanTilt>
                    <Zoom>true</Zoom>
                </Stop>";
            
            await _session.SendCommandAsync(soapBody);
        }

        private async Task ExecutePtzCommand(string command, string category, 
            double x = 0, double y = 0, double z = 0)
        {
            var soapBody = $@"
                <{command} xmlns=""http://www.onvif.org/ver20/ptz/wsdl"">
                    <ProfileToken>MainProfile</ProfileToken>
                    <Velocity>
                        <PanTilt x=""{x}"" y=""{y}"" xmlns=""http://www.onvif.org/xsd""/>
                        <Zoom x=""{z}"" xmlns=""http://www.onvif.org/xsd""/>
                    </Velocity>
                ";

            await _session.SendCommandAsync(soapBody);
        }
    }
}

5. 네트워크 버퍼링 및 최적화

5.1 버퍼 관리 전략

안정적인 스트리밍 재생을 위해 네트워크 버퍼를 적절히 관리해야 합니다. 버퍼 크기는 네트워크 지연과 대역폭에 따라 동적으로 조정하는 것이 좋습니다:

using System;
using System.Collections.Concurrent;

namespace StreamBuffer
{
    public class NetworkBuffer
    {
        private readonly ConcurrentQueue _buffer;
        private readonly int _maxBufferSize;
        private readonly object _syncLock = new object();

        public NetworkBuffer(int maxSize = 100)
        {
            _maxBufferSize = maxSize;
            _buffer = new ConcurrentQueue();
        }

        public void AddData(byte[] data)
        {
            lock (_syncLock)
            {
                if (_buffer.Count >= _maxBufferSize)
                {
                    // 가장 오래된 데이터 제거
                    if (_buffer.TryDequeue(out _))
                    {
                        Console.WriteLine("버퍼가 가득 찼습니다. 이전 데이터를 제거합니다.");
                    }
                }
                _buffer.Enqueue(data);
            }
        }

        public bool TryGetData(out byte[] data)
        {
            return _buffer.TryDequeue(out data);
        }

        public int CurrentSize => _buffer.Count;

        public bool IsEmpty => _buffer.IsEmpty;
    }
}

5.2 스트림 동기화 처리

오디오와 비디오의 동기화를 위해 타임스탬프를 기반으로 프레임을 정렬하는 로직이 필요합니다:

using System;
using System.Collections.Generic;

namespace StreamSync
{
    public class FrameSynchronizer
    {
        private SortedDictionary _videoFrames;
        private SortedDictionary _audioFrames;
        private long _videoPts;
        private long _audioPts;

        public void AddVideoFrame(byte[] frameData, long pts)
        {
            _videoFrames[pts] = frameData;
            _videoPts = pts;
        }

        public void AddAudioFrame(byte[] frameData, long pts)
        {
            _audioFrames[pts] = frameData;
            _audioPts = pts;
        }

        public (byte[] video, byte[] audio) GetSyncFrames()
        {
            long targetPts = Math.Min(_videoPts, _audioPts);
            
            _videoFrames.TryGetValue(targetPts, out var videoFrame);
            _audioFrames.TryGetValue(targetPts, out var audioFrame);

            return (videoFrame, audioFrame);
        }
    }
}

본 article에서는 C# 환경에서 ONVIF 프로토콜과 RTSP를 활용한 네트워크 비디오 스트리밍 구현 방법을 다루었습니다. VLC 플레이어와의 연동을 통해 다양한 비디오 포맷을 지원하며, PTZ 제어를 통해 원격 카메라 조작이 가능합니다.

태그: onvif rtsp csharp video-streaming vlc

6월 1일 18:00에 게시됨