WPF에서 OpenCV로 RTSP영상 재생시 메모리 누수 문제

C# WPF를 이용해 cctv 프로그램을 만들고 있습니다.

i5-14세대(내장 그래픽), 램 32기가, SSD 1테라, 파워 700W정도 스펙의 컴퓨터를 사용중입니다
OS는 윈도우이며 간섭이 생길걸로 예상되는 프로그램은 없습니다(기본 상태에서 닷넷런타임, 브라우저에서 특정 데이터를 주고 받을때 사용되는 안랩만 설치)

프로그램은 의도한대로 동작하지만, 장시간 켜두면 점점 사용 메모리량이 증가되다가, 최대치를 찍으며 강제 종료되는 문제가 있었습니다.
첫 실행후 30분~1시간정도는 200~500mb를 유지하다가, 그 이후부터 조금씩 늘어납니다.

RTSP로 카메라 2~4대를 연결하고 실시간으로 조회하고 있는 상태이며
카메라는 1대만 FHD, 나머지는 HD로 세팅해두었습니다 (비트레이트는 기본값인 ‘적정/보통’)
인코딩은 H.264로 하고 있습니다 (265로 하면 cpu 자원을 많이 먹는다 하여 264로 했습니다)

문제는 N100, 램 16기가, SSD 512기가 미니PC에서는 램은 많이 먹을지언정 강종 없이 몇개월이상 정상동작하고 있는데요

시험삼아서 저가 그래픽카드(1050, 1060등)을 꽂아보니 매모리 누수로 추정되는 증상이 없어지고 강종없이 몇주간 정상동작되는걸 확인했습니다.

현재는 사용하는 PC에 1060 6gb를 추가하여 사용하고 있습니다.

잘 이해가 되지 않아서, 16기가램+ssd512는 동일하게 하되 다른 cpu로 테스트해보았습니다

i5-14세대(내장 그래픽) → 메모리 누수 발생
i5-12세대(내장 그래픽) → 메모리 누수 발생
5600g(내장 그래픽) → 메모리 누수 발생 x
6600H(내장 그래픽) → 메모리 누수 발생 x
7735hs(내장 그래픽) → 메모리 누수 발생 x
i5-12세대+1050 3gb → 메모리 누수발생 x
N100 → 메모리는 거의 최대치를 사용하지만 강종 없음

이래서 그냥 저 혼자 생각으로 그래픽처리가 제대로 안되어서 그렇다고 결론을 내렸습니다

이후 파이썬을 다루게 되어, 파이썬으로 위에서 사용한 프로그램을 클론하여서 만들고 있었는데

메모리 누수가 발생한 i5-12세대에서 낮은 램 사용량(100~300mb미만)을 유지하며 며칠을 켜두어도 이상이 없었습니다.

그렇다면, C#으로 코딩한 프로그램을 제대로 못 만들었기 때문이라고 밖에 생각할 수 없더라구요
그게 아니면 파이썬에서 비디오 관련 램처리를 자동으로… 해주는거 같진 않지만…

다른델 찾아보아도 별다른 답변이 없어서 남겨봅니다.

코드는 제가 코딩한 것 과 검색해서 찾은 걸 조합해서 만들어져 있고
지피티 유료 모델, 클로드 유료 모델을 사용해봤으니 유의미한 결과는 못 얻었습니다

사용한 코드는 아래와 같습니다.
코드가 생각보다 길어져서, 일부는 삭제한 코드입니다

private void CCTV_RUN(int index)
{
    Mat mat = null;
    VideoCapture capture = null;

    try
    {
        int openFailCount = 0;
        string addr = "rtsp 주소";
        if (string.IsNullOrEmpty(addr)) return;

        capture = new VideoCapture();
        capture.Open(addr);

        DateTime lastResetTime = DateTime.Now;

        while (cctvRuns[index])
        {
            try
            {                   
                mat = new Mat();
                bool hasFrame = capture.Read(mat);

                if (!hasFrame || mat.Empty())
                {

                    mat.Dispose();
                    capture?.Release();
                    capture?.Dispose();
                    capture = new VideoCapture();
                    Thread.Sleep(1000);
                    continue;
                }

                lock (ScenLock[index])
                {
                    LastScen[index]?.Dispose();
                    LastScen[index] = mat.Clone();
                }

                while (VideoList[index].TryTake(out var oldMat))
                    oldMat.Dispose();

                if (VideoList[index].Count < 1)
                    VideoList[index].Add(mat.Clone());


                // 화면 출력용
                Dispatcher.InvokeAsync(() =>
                {
                    try
                    {
                        if (VideoList[index].TryTake(out Mat item))
                        {
                            try
                            {
                                if (!item.Empty())
                                    images[index].Source = WriteableBitmapConverter.ToWriteableBitmap(item);
                            }
                            finally
                            {
                                item.Dispose();
                            }
                        }
                    }
                    catch (Exception e)
                    {
                    }
                }, DispatcherPriority.Background);

                mat.Dispose();
            }
            catch (Exception e)
            {
                Thread.Sleep(1000);
                break;
            }
        }
    }
    finally
    {
        mat?.Dispose();
        capture?.Release();
        capture?.Dispose();
    }
}

images[index].Source = WriteableBitmapConverter.ToWriteableBitmap(item);

저는 이 부분이 의심스럽네요
매 프레임 마다 새로운 WriteableBitmap이 생길 것 같아서요

WriteableBitmapConverter.ToWriteableBitmap(item, (WriteableBitmap)images[index].Source);

ImageSource가 GC에 의해 관리되지 않고,
images[index].Source가 Dispose 되는 친구가 아니라면
이런 식으로 시도해볼 것 같습니다.

대신에 미리 할당한 ImageSource의 크기랑 픽셀 포맷을 직접 관리해야겠네요

같은 실행파일 다른 머신에서 했을때
결과가 다르게 나온다면

해당 기기의 드라이버 업데이트를 고려해 보시면 어떨까 싶습니다.

저희쪽도 그래픽 프로그램 개발 중인데
특정 PC에서 그래픽 문제가 있어 확인 결과
원인이 드라이버 였습니다.

저는 예전에

비트맵 이미지에서 값을 복사하려고 LockBits하여 사용 후 dispose 해버렸는데,

계속 메모리가 올라가 프로그램이 죽어 결국 원인은

LockBits 호출 뒤 Dispose하면 해제가 되지 않아
LockBits → UnlockBits → Dispose 를 해야 메모리에서 해제가 되더라구요…

VideoList 가 무슨 타입인가요?

답변 감사합니다.

BlockingCollection<Mat> VideoList; 으로 선언하여 사용하고 있습니다

사실 이부분은 제가 이해가 안되어서 검색한걸 베이스로 했었는데

저는 영상(사진)객체에 락을 걸어야한다고 생각했지만 실제로 락이 되는 요소는 비어있는 오브젝트 타입입니다.

object ScenLock;
ScenLock = new object[2] { new object(), new object() };

private void MonitorEnter(out bool locked0, out bool locked1)
{
locked0 = false;
locked1 = false;
Monitor.Enter(ScenLock[0], ref locked0);
Monitor.Enter(ScenLock[1], ref locked1);
}

여긴 그냥 나온대로 해야지~ 하고 스르륵 넘어간 부분이라 파악을 못했네요
여기도 좀 더 알아보겠습니다

드라이버는 모두 최신 상태로 유지하고 있습니다

누수로 의심되는 증상은 그래픽카드가 들어가거나
동일한 기능을 파이썬으로 구현하면 해결되어서

드라이버 문제보다는 제가 작성한 C# 코드가 문제라고 예상하고 있습니다

말씀하신 방식으로 테스트 해보겠습니다, 감사합니다

제가 의심되는 부분은

while (VideoList[index].TryTake(out var oldMat))
                    oldMat.Dispose();

                if (VideoList[index].Count < 1)
                    VideoList[index].Add(mat.Clone());


                // 화면 출력용
                Dispatcher.InvokeAsync(() =>
                {
                    try
                    {
                        if (VideoList[index].TryTake(out Mat item))

이 부분이네요.
처음보는 타입이라서 그냥 추측입니다. BlockingCollection?
고사양pc에서 상위 while문은 그냥 스트레이트로 쭉 도는데(sleep 같은 쉼이 없으니)
capture에서는 카메라의 ips는 고려하지 않고 그냥 기존거 계속 줄겁니다.
그래서 계속 빠르게 상위 while문이 돌죠.
이상태에서 videolist의 소비자는 비동기로 메인스레드에 던져지는 작업에 있죠.
근데 상위 while 내부에 while이 또 있죠. 여기서도 소비를 합니다.
BlockingCollection 읽어보니까 텅비면 스레드 멈추도록 되어있대요.
그러면 메인스레드와 이 while문은 핑퐁핑퐁 동작할 것 같지만 비동기니까
언젠가 성능차이로 인해? while문의 소비자가 소비를 먼저 하는 시점이 왔는데 걸려있는 메인스레드가 있다면 메인스레드는 멈춰서 기다릴거에요. 근데 while문은 그냥 갈길 가서 메인스레드에 또 소비자를 예약을 걸죠.
저 BlockingCollection이 소비자들의 순서를 지켜준다면 괜찮은데 소비자들의 순서는 고려안하고 동시성만 안전하게 하는 구조라면? (그런지는 몰라요 추측임) 그 상태에서 while문이 계속 소비를 먼저 한다면? 시간이 가면서 메인스레드에 비동기로 거는 작업이 쭉 쌓이지 않을까요?
그래서 Mat이 쌓이는게 아니라 작업큐? 랄까 그게 쌓여서 뻗는거 아닐까요? 메모리 증가는 작업큐의 메모리증가거나 그게 아니라 메인스레드의 병목으로 인해 부작용으로 다른데서 차오른다던가요.

자문자답(?)이 될 수 있는데…

스크린샷의 위에 있는 rtsp는 기존 로직을 따라가는거라 보면 되고
아래 있는 rtsp_test는 새로 작성한 로직으로 구동중인 앱입니다

 Dispatcher.InvokeAsync(() =>
 {
     try
     {
         if (VideoList[index].TryTake(out Mat item))
         {
             try
             {
                 if (!item.Empty())
                 {

                     // 기존 로직
                     //images[index].Source = WriteableBitmapConverter.ToWriteableBitmap(item);

                     // 기존 로직과 별 차이가 없었음
                     //images[index].Source = null; // 이전 WriteableBitmap 해제
                     //images[index].Source = WriteableBitmapConverter.ToWriteableBitmap(item);


                     var bitmap = reusableBitmaps[index];
                     if (bitmap.PixelWidth != item.Width || bitmap.PixelHeight != item.Height)
                     {
                         bitmap = new WriteableBitmap(item.Width, item.Height, 96, 96, PixelFormats.Bgr24, null);
                         reusableBitmaps[index] = bitmap;
                     }

                     bitmap.Lock(); 

                     unsafe
                     {
                         // 포인터 접근
                         void* srcPtr = (void*)item.Data;
                         void* dstPtr = (void*)bitmap.BackBuffer;
                         int totalBytes = (int)item.Step() * item.Rows;

                         System.Runtime.CompilerServices.Unsafe.CopyBlock(
                             destination: dstPtr,
                             source: srcPtr,
                             byteCount: (uint)totalBytes
                         );
                     }

                     bitmap.AddDirtyRect(new Int32Rect(0, 0, item.Width, item.Height));
                     bitmap.Unlock();


                     images[index].Source = bitmap;                                            
                 }
             }
             finally
             {
                 item.Dispose();
             }
         }
     }
     catch (Exception e)
     {
         Console.WriteLine(e.Message);
     }
 }, DispatcherPriority.Background);

var bitmap 부터 images[index].source = bitmap; 까지가 신규로 작성한 코드입니다.

어제 낮 3~4시에 구동시키로 하루가ㅓ 지난 방금 전에 스크린샷 찍은건데

신규 코드로 처리한게 램 사용량이 원래 예상되던 사용량이네요

저렇게 하는건 또 처음본거라 잘 몰라서, 써도 문제가 안되는지 조금 걱정이=네요
ide에서는 안전하지 않은 코드라고 알림을 띄우긴 하는데…

1개의 좋아요

unsafe 키워드 사용으로 프로젝트에서 unsafe 설정을 켜줘야 해서 IDE에서 알림이 발생하신 것 같습니다.
Unsafe.CopyBlock 대신 Marshal.Copy를 사용해도 거의 동일한 성능을 낼 수 있습니다.
다만, 원본 reusableBitmap이 IntPtr을 멤버로 들고 있어야 하긴 합니다.

bitmap.Lock();
Marshal.Copy(srcPtr, 0, dstPtr, dataLength);
bitmap.AddDirtyRect(new Int32Rect(0, 0, item.Width, item.Height));
bitmap.Unlock();

영상처리 중에 unsafe도 종종 사용한적이 있어서 주석 잘 달아주고 unsafe 메서드끼리 격리만 잘 해두고 사용하면 괜찮았던 것 같네요.
이번 경우에는 Marshal.Copy로도 변경 가능해 보여서 제안드립니다.

1개의 좋아요