From 55e22ddcc35f05a1ae4f11bc50d7e15511e92dff Mon Sep 17 00:00:00 2001 From: hj615 Date: Thu, 28 Aug 2025 10:47:27 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20CCTV=20=EC=9E=AC=EC=83=9D=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SmartAquaViewer/Classes/RtspImageBehaviour.cs | 228 ++++++++++++++++++ .../Helper/FFHelper/StreamDecoder.cs | 30 ++- SmartAquaViewer/SmartAquaViewer.csproj | 2 +- SmartAquaViewer/View/CCTVView.xaml | 35 ++- SmartAquaViewer/ViewModel/CCTVViewModel.cs | 22 +- .../ViewModel/GraphControlViewModel.cs | 4 +- SmartAquaViewer/ViewModel/MainViewModel.cs | 3 + 7 files changed, 315 insertions(+), 9 deletions(-) create mode 100644 SmartAquaViewer/Classes/RtspImageBehaviour.cs diff --git a/SmartAquaViewer/Classes/RtspImageBehaviour.cs b/SmartAquaViewer/Classes/RtspImageBehaviour.cs new file mode 100644 index 0000000..ff3e728 --- /dev/null +++ b/SmartAquaViewer/Classes/RtspImageBehaviour.cs @@ -0,0 +1,228 @@ +using System; +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Drawing; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using System.Windows.Threading; +using FFmpeg.AutoGen; +using SmartAquaViewer.Helper.FFHelper; + +namespace SmartAquaViewer.Classes +{ + public static class RtspImageBehavior + { + public static readonly DependencyProperty SourceUrlProperty = + DependencyProperty.RegisterAttached( + "SourceUrl", typeof(string), typeof(RtspImageBehavior), + new PropertyMetadata(null, OnSourceUrlChanged)); + + public static void SetSourceUrl(DependencyObject obj, string value) => obj.SetValue(SourceUrlProperty, value); + public static string GetSourceUrl(DependencyObject obj) => (string)obj.GetValue(SourceUrlProperty); + + private static readonly DependencyProperty PlayerProperty = + DependencyProperty.RegisterAttached( + "Player", typeof(ImageRtspAdapter), typeof(RtspImageBehavior), new PropertyMetadata(null)); + + private static void OnSourceUrlChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is not System.Windows.Controls.Image img) return; + + // 1) 기존 플레이어 정리 + if (img.GetValue(PlayerProperty) is ImageRtspAdapter old) + { + old.Dispose(); + img.ClearValue(PlayerProperty); + img.Source = null; + img.Visibility = Visibility.Collapsed; + } + + // 2) 신규 URL 연결 + var url = e.NewValue as string; + if (string.IsNullOrWhiteSpace(url)) return; + + var player = new ImageRtspAdapter(img, url); + img.SetValue(PlayerProperty, player); + player.Start(); + + img.Unloaded -= OnUnloaded; + img.Unloaded += OnUnloaded; + } + + private static void OnUnloaded(object sender, RoutedEventArgs e) + { + if (sender is System.Windows.Controls.Image img && img.GetValue(PlayerProperty) is ImageRtspAdapter p) + { + p.Dispose(); + img.ClearValue(PlayerProperty); + } + if (sender is System.Windows.Controls.Image i) i.Unloaded -= OnUnloaded; + } + } + + public sealed class ImageRtspAdapter : IDisposable + { + private readonly System.Windows.Controls.Image _img; + private readonly string _url; + + private Thread? _videoThread; + private Thread? _renderThread; + private CancellationTokenSource? _videoCts; + private CancellationTokenSource? _renderCts; + + private readonly Queue _frameQueue = new(); // lock 불필요 + private volatile bool _disposed; + + // 큐 최대 길이(지연 방지용): 최신 프레임 위주로 보여주기 + private const int MaxQueue = 2; + + private object _lockObject = new(); + + public ImageRtspAdapter(System.Windows.Controls.Image img, string url) + { + _img = img; + _url = url; + } + + public void Start() + { + _videoCts = new CancellationTokenSource(); + _renderCts = new CancellationTokenSource(); + + _videoThread = new Thread(OpenMedia) { IsBackground = true, Name = "RTSP-Decode" }; + _renderThread = new Thread(RenderLoop) { IsBackground = true, Name = "RTSP-Render" }; + + _videoThread.Start(); + _renderThread.Start(); + } + + private unsafe void OpenMedia() + { + try + { + using (var sd = new StreamDecoder(_url)) + using (var vfc = new VideoFrameConverter(sd.FrameSize, sd.PixelFormat, sd.FrameSize, AVPixelFormat.AV_PIX_FMT_BGR24)) + { + while (!_videoCts!.IsCancellationRequested) + { + if (!sd.TryDecodeNextFrame(out var frame)) + { + // 디코드 실패: 너무 바쁘지 않게 살짝 쉼 + Thread.Sleep(2); + continue; + } + + try + { + AVFrame convertedFrame = vfc.Convert(frame); + EnqueueFrame(convertedFrame); // 큐 삽입 (길이 제한 적용) + } + catch (Exception ex) + { + Debug.WriteLine("Decode/Enqueue error: " + ex.Message); + } + } + } + } + catch (Exception ex) + { + Debug.WriteLine("OpenMedia() : " + ex); + } + } + + private void EnqueueFrame(AVFrame avf) + { + _frameQueue.Enqueue(avf); + } + + private unsafe void RenderLoop() + { + try + { + while (!_renderCts!.IsCancellationRequested) + { + if (_frameQueue.Count > 0) + { + AVFrame convertedFrame; + lock (_lockObject) + { + if (_frameQueue.Count > 0) + { + convertedFrame = _frameQueue.Dequeue(); + } + else + { + continue; + } + } + try + { + Bitmap bitmap = new Bitmap(convertedFrame.width, convertedFrame.height, convertedFrame.linesize[0], System.Drawing.Imaging.PixelFormat.Format24bppRgb, (IntPtr)convertedFrame.data[0]); + + var src = CreateBitmapSource(bitmap); + src.Freeze(); + + _img.Dispatcher.BeginInvoke(new Action(() => + { + if (_disposed) { return; } + _img.Source = src; + if (_img.Visibility != Visibility.Visible) + _img.Visibility = Visibility.Visible; + }), DispatcherPriority.Render); + } + catch (Exception ex) + { + Debug.WriteLine("RenderLoop() : " + ex.Message); + } + } + } + } + catch (OperationCanceledException) { /* 정상 종료 */ } + finally + { + //ClearQueue(); + } + } + + private static BitmapSource CreateBitmapSource(Bitmap bitmap) + { + // GDI 핸들 사용 X. LockBits → BitmapSource.Create 경로만 사용. + var rect = new System.Drawing.Rectangle(0, 0, bitmap.Width, bitmap.Height); + var data = bitmap.LockBits(rect, System.Drawing.Imaging.ImageLockMode.ReadOnly, bitmap.PixelFormat); + try + { + // Format24bppRgb ↔ PixelFormats.Bgr24 매칭 + return BitmapSource.Create( + data.Width, data.Height, 96, 96, + PixelFormats.Bgr24, null, + data.Scan0, data.Stride * data.Height, data.Stride); + } + finally + { + bitmap.UnlockBits(data); + } + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + try { _videoCts?.Cancel(); } catch { } + try { _renderCts?.Cancel(); } catch { } + + try { if (_videoThread?.IsAlive == true) _videoThread.Join(300); } catch { } + try { if (_renderThread?.IsAlive == true) _renderThread.Join(300); } catch { } + + _frameQueue.Clear(); + + _videoCts?.Dispose(); + _renderCts?.Dispose(); + } + } +} diff --git a/SmartAquaViewer/Helper/FFHelper/StreamDecoder.cs b/SmartAquaViewer/Helper/FFHelper/StreamDecoder.cs index 00d3039..af22548 100644 --- a/SmartAquaViewer/Helper/FFHelper/StreamDecoder.cs +++ b/SmartAquaViewer/Helper/FFHelper/StreamDecoder.cs @@ -7,6 +7,7 @@ using System.Text; using System.Threading.Tasks; using FFmpeg.AutoGen; using System.Windows; +using System.IO; namespace SmartAquaViewer.Helper.FFHelper { @@ -29,8 +30,34 @@ namespace SmartAquaViewer.Helper.FFHelper public bool _isContextNotInitialized = false; public double _fps; + private static bool _ffInited; + + private static void EnsureFFmpegLoaded() + { + if (_ffInited) return; + + // 실행폴더\ffmpeg 강제 지정 (RootPath는 어떤 ffmpeg.* 호출보다 먼저!) + var root = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "ffmpeg"); + ffmpeg.RootPath = root; + + // 네트워크 초기화 (중복 호출 무해) + ffmpeg.avformat_network_init(); + + // 여기서 한번 강제로 심볼 로딩 검증 + var ver = ffmpeg.av_version_info(); + + _ffInited = true; + } + + private static void SetLogLevelSafe(int level) + { + try { ffmpeg.av_log_set_level(level); } + catch (NotSupportedException) { /* 일부 조합에서만 실패 → 그냥 무시 */ } + } + public StreamDecoder(string url) { + //Application.Current.Dispatcher.Invoke(() => //{ // ProgressWindow.GetInstance().Show(); @@ -45,7 +72,8 @@ namespace SmartAquaViewer.Helper.FFHelper try { - ffmpeg.av_log_set_level(ffmpeg.AV_LOG_DEBUG); + EnsureFFmpegLoaded(); // ★ 가장 첫 줄 + SetLogLevelSafe(ffmpeg.AV_LOG_ERROR); _pFormatContext = ffmpeg.avformat_alloc_context(); if (_pFormatContext == null) diff --git a/SmartAquaViewer/SmartAquaViewer.csproj b/SmartAquaViewer/SmartAquaViewer.csproj index 6dd7f73..6fbd482 100644 --- a/SmartAquaViewer/SmartAquaViewer.csproj +++ b/SmartAquaViewer/SmartAquaViewer.csproj @@ -40,7 +40,7 @@ - + diff --git a/SmartAquaViewer/View/CCTVView.xaml b/SmartAquaViewer/View/CCTVView.xaml index 44720c7..c21fbf1 100644 --- a/SmartAquaViewer/View/CCTVView.xaml +++ b/SmartAquaViewer/View/CCTVView.xaml @@ -4,6 +4,7 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:local="clr-namespace:SmartAquaViewer.View" + xmlns:classes="clr-namespace:SmartAquaViewer.Classes" mc:Ignorable="d" d:DesignHeight="450" d:DesignWidth="800"> @@ -15,7 +16,39 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/SmartAquaViewer/ViewModel/CCTVViewModel.cs b/SmartAquaViewer/ViewModel/CCTVViewModel.cs index 56abcb8..482a7d1 100644 --- a/SmartAquaViewer/ViewModel/CCTVViewModel.cs +++ b/SmartAquaViewer/ViewModel/CCTVViewModel.cs @@ -68,28 +68,42 @@ namespace SmartAquaViewer.ViewModel { DeviceId = "000003", DeviceName = "CCTV 3", - RtspUrl = "rtsp://210.217.121.58:8554/CAM-211", + RtspUrl = "rtsp://210.217.121.58:8554/CAM-01", Status = "Active" }, new() { DeviceId = "000004", DeviceName = "CCTV 4", - RtspUrl = "rtsp://210.217.121.58:8554/CAM-212", + RtspUrl = "rtsp://210.217.121.58:8554/CAM-02", Status = "Active" }, new() { DeviceId = "000005", DeviceName = "CCTV 5", - RtspUrl = "rtsp://210.217.121.58:8554/CAM-211", + RtspUrl = "rtsp://210.217.121.58:8554/CAM-03", Status = "Active" }, new() { DeviceId = "000006", DeviceName = "CCTV 6", - RtspUrl = "rtsp://210.217.121.58:8554/CAM-212", + RtspUrl = "rtsp://210.217.121.58:8554/CAM-04", + Status = "Active" + }, + new() + { + DeviceId = "000007", + DeviceName = "CCTV 7", + RtspUrl = "rtsp://210.217.121.58:8554/CAM-05", + Status = "Active" + }, + new() + { + DeviceId = "000008", + DeviceName = "CCTV 8", + RtspUrl = "rtsp://210.217.121.58:8554/CAM-06", Status = "Active" } }; diff --git a/SmartAquaViewer/ViewModel/GraphControlViewModel.cs b/SmartAquaViewer/ViewModel/GraphControlViewModel.cs index c02aad3..59ca9fe 100644 --- a/SmartAquaViewer/ViewModel/GraphControlViewModel.cs +++ b/SmartAquaViewer/ViewModel/GraphControlViewModel.cs @@ -340,7 +340,7 @@ namespace SmartAquaViewer.ViewModel } // 3) 팔레트 - var colors = OxyPalettes.HueDistinct(tankIds.Count).Colors; + var colors = OxyPalettes.Hot(tankIds.Count).Colors; for (int k = 0; k < tankIds.Count; k++) { @@ -441,7 +441,7 @@ namespace SmartAquaViewer.ViewModel if (tankIds.Count == 0) { Model.InvalidatePlot(true); return; } // 색상/마커 - var colors = OxyPalettes.HueDistinct(tankIds.Count).Colors; + var colors = OxyPalettes.Hot(tankIds.Count).Colors; var markerCycle = new[] { MarkerType.Circle, MarkerType.Square, MarkerType.Triangle, MarkerType.Diamond, diff --git a/SmartAquaViewer/ViewModel/MainViewModel.cs b/SmartAquaViewer/ViewModel/MainViewModel.cs index e332f84..5f9826b 100644 --- a/SmartAquaViewer/ViewModel/MainViewModel.cs +++ b/SmartAquaViewer/ViewModel/MainViewModel.cs @@ -7,6 +7,7 @@ using System.Runtime.CompilerServices; using System.Text; using System.Threading.Tasks; using System.Windows.Input; +using FFmpeg.AutoGen; using Newtonsoft.Json; using SmartAquaViewer.Controls; using SmartAquaViewer.DataAnalysis; @@ -54,6 +55,8 @@ namespace SmartAquaViewer.ViewModel SelectedViewModel = new MonitoringViewModel(); // Default view + ffmpeg.RootPath = $"{Environment.CurrentDirectory}\\ffmpeg"; + //더미데이터 생성 및 파일로 저장 //for (int i = 1; i <= 10; i++) //{