Program/C#

C# Bitmap

사막여유 2024. 11. 18. 23:11
728x90

 

오늘은 C#의 Bitmap에 대해서 알아보고자 합니다.

Bitmap은 C/C++ 에서도 있는 개념입니다.
C/C++에서 Bitmap은 구조체로 구현이 되어있고 데이터와 이를 다루는 함수가 분리되어 있습니다.
그에 반해 C#에서 Bitmap은 클래스로 구현이 되어있고 데이터와 매서드가 클래스에 캡슐화 되어 있습니다.

 

 

C#에서 Bitmap클래스는 ( 위 이미지에서는 메서드만 나와있지만 )

데이터인 Width, Hieght, PixelFormat(픽셀형식) , HorizontalResolution, VerticalResolution, Flags, Palette 등의 데이터도
직접 접근할 수 있도록 캡슐화 되어 있습니다.

그에 반해 C / C++에서의 Bitmap은 windows Gdi ( Graphic Device Interface ) API 의 기본 구조체인 wingid.h의 tagBITMAP의 구조체로만 구성이 되어있고
때문에 Bitmap에 관련된 함수는 따로 구성되어있지 않습니다.

물론 C++ MFC에서는 Gui Component Contorl들이 있기 때문에 CBitmap 이라는 클래스가 제공되기는 합니다.

 

그럼 우리는 C#이니 C#에서 주로 다루고 있는 데이터 및 메서드에 대해서 구체적으로 알아보겠습니다.

 

첫번째로 가장 기본적인 픽셀 접근에 대한 메서드에 대해서 살펴보겠습니다.

using System.Drawing;

// 비트맵 생성
Bitmap bmp = new Bitmap(800, 600); // 빈 비트맵
Bitmap fromFile = new Bitmap("image.jpg"); // 파일에서 로드

// 픽셀 데이터 접근 및 수정
void ProcessImage(Bitmap bmp)
{
    // GetPixel/SetPixel - 간단하지만 느림
    Color pixel = bmp.GetPixel(10, 10);
    bmp.SetPixel(10, 10, Color.Red);

    // LockBits - 빠른 픽셀 접근
    Rectangle rect = new Rectangle(0, 0, bmp.Width, bmp.Height);
    System.Drawing.Imaging.BitmapData bmpData = 
        bmp.LockBits(rect, System.Drawing.Imaging.ImageLockMode.ReadWrite,
                     bmp.PixelFormat);
}

 

Bitmap 클래스에서 픽셀 데이터에 접근 및 수정하는 메서드는
GetPixel / SetPixel 과 LockBits 이렇게 2가지로 구성되어 있습니다.

위 주석처리에서도 나와있듯이 GetPixel 과 SetPiexel은 접근 및 사용하기 편리하지만 처리속도가 느리고
LockBits는 접근 및 사용하기 상대적으로 어렵지만 처리속도가 빠릅니다.

 

 

private void BtnTestGetPixel_Click(object sender, EventArgs e)
{
    Bitmap workingBmp = new Bitmap(sourceBmp);
    var sw = Stopwatch.StartNew();

    for (int x = 0; x < workingBmp.Width; x++)
    {
        for (int y = 0; y < workingBmp.Height; y++)
        {
            Color pixel = workingBmp.GetPixel(x, y);
            workingBmp.SetPixel(x, y, Color.FromArgb(255 - pixel.R, 255 - pixel.G, 255 - pixel.B));
        }
    }

    sw.Stop();
    lblResult.Text = $"GetPixel/SetPixel 처리 시간: {sw.ElapsedMilliseconds}ms";
    pictureBox.Image = workingBmp;
}

 

private void BtnTestLockBits_Click(object sender, EventArgs e)
{
    Bitmap workingBmp = new Bitmap(sourceBmp);
    var sw = Stopwatch.StartNew();

    Rectangle rect = new Rectangle(0, 0, workingBmp.Width, workingBmp.Height);
    System.Drawing.Imaging.BitmapData bmpData = workingBmp.LockBits(rect,
        System.Drawing.Imaging.ImageLockMode.ReadWrite,
        System.Drawing.Imaging.PixelFormat.Format24bppRgb);

    try
    {
        IntPtr ptr = bmpData.Scan0;
        int bytes = Math.Abs(bmpData.Stride) * workingBmp.Height;
        byte[] rgbValues = new byte[bytes];

        System.Runtime.InteropServices.Marshal.Copy(ptr, rgbValues, 0, bytes);

        for (int i = 0; i < rgbValues.Length; i += 3)
        {
            rgbValues[i] = (byte)(255 - rgbValues[i]);
            rgbValues[i + 1] = (byte)(255 - rgbValues[i + 1]);
            rgbValues[i + 2] = (byte)(255 - rgbValues[i + 2]);
        }

        System.Runtime.InteropServices.Marshal.Copy(rgbValues, 0, ptr, bytes);
    }
    finally
    {
        workingBmp.UnlockBits(bmpData);
    }

    sw.Stop();
    lblResult.Text = $"LockBits 처리 시간: {sw.ElapsedMilliseconds}ms";
    pictureBox.Image = workingBmp;
}

 

위 2가지 메서드의 주요 차이점은


GetPixel / SetPixel 에서는 매 호출마다 메모리 접근이 발생하여 오버헤드가 크지만
LockBits는 메모리를 한번에 잠그고 직접 접근하여 효율적입니다.

예를들어 위 영상처럼 1000x1000 이미지의 픽셀에 접근하여 각각의 픽셀값을 바꾼다고 하면
GetPixel / SetPixel 호출 시 각 픽셀을 Get, Set 하여 총 1,000,000번 호출하게 되지만
LockBits를 사용하면 메모리에 직접 접근하기 때문에 1번의 호출만 필요하게 됩니다.

 

혹시 헷갈리면 안되는 부분은 LockBits는 Get/Set Pixel처럼 픽셀에 직접 접근해서 뭔가를 바꿔주는 메서드가 아닙니디.

LockBits는 비트맵의 메모리 영역을 잠그고 직접 접근할 수 있게 권한을 얻는 메서드 입니다.

위 이미지로 정확하게 설명이 가능한데 
Lock Bits로 울타리를 쳐서 다른쓰레드나 프로세스(늑대) 들이 픽셀(돼지)에 접근할 수 없도록 메모리(농장) 주변을 
감싸고  내 마음대로 이리저리 조리해버리는 것이죠.

그래서 위 BtnTestLockBits_Click메서드의 픽셀 변환 부분을 자세히 보게되면

try
{
    IntPtr ptr = bmpData.Scan0;
    int bytes = Math.Abs(bmpData.Stride) * workingBmp.Height;
    byte[] rgbValues = new byte[bytes];

    System.Runtime.InteropServices.Marshal.Copy(ptr, rgbValues, 0, bytes);
    System.Runtime.InteropServices.Marshal.Copy()

    for (int i = 0; i < rgbValues.Length; i += 3)
    {
        rgbValues[i] = (byte)(255 - rgbValues[i]);
        rgbValues[i + 1] = (byte)(255 - rgbValues[i + 1]);
        rgbValues[i + 2] = (byte)(255 - rgbValues[i + 2]);
    }

    System.Runtime.InteropServices.Marshal.Copy(rgbValues, 0, ptr, bytes);
}

위 코드처럼
ptr에 전역으로 사용되고 있는 bmp 데이터의 첫번째 메모리 주소를 입력한 뒤
bytes에 해당 데이터의 전체 바이트 수를 계산하고,
새로운 rgbValues라는 위 전체 바이트수만큼의 bytes배열을 만들고,
각 bytes배열에 값을 변환시켜 넣어준 뒤 
전역으로 사용되고 있는 bmp 데이터의 메모리 주소에 접근하여 값을 한번에 바꿔주게 되는

위와같이 개발자의 마음대로 조리한 뒤 다시 자유롭게 접근할 수 있도록 풀어줄 수 있는 구조인거죠.

 

그런데 만약 하나하나의 픽셀에 접근해야 한다고 하면 어떨까요?

그래도 여전히 LockBits는 유효하게 동작합니다.
Set,GetPixel 메소드 에서 픽셀 하나에 접근할때마다 내부적으로 락/언락 작업이 발생하지만 LockBits를 해준 이후에는 전제 작업에서 한번만 발생하기 때문입니다.

 

두번째로는 Bitmap의 주요 이미지 처리 기능에 대해서 알아보겠습니다.

Bitmap의 주요 이미지 처리 기능에는 Clone(), Scale(), RotateFlip() 등의 메서드가 있습니다.

각각 기능을 먼저 설명하면
Clone()은 이미지를 복제하는 기능
Sclae()은 이미지의 사이즈를 조절하는 기능
RotateFlip()은 이미지를 회전, 수평수직뒤집기 기능 입니다.

 

Clone()

private void BtnClone_Click(object sender, EventArgs e)
{
    var sw = Stopwatch.StartNew();

    // Clone을 사용하여 이미지 복사
    Bitmap clonedBmp = (Bitmap)sourceBmp.Clone();

    // 복제된 이미지에 빨간색 사각형 추가
    using (Graphics g = Graphics.FromImage(clonedBmp))
    {
        using (SolidBrush brush = new SolidBrush(Color.FromArgb(128, 255, 0, 0)))
        {
            g.FillRectangle(brush, 200, 200, 600, 600);
        }
    }

    sw.Stop();
    lblResult.Text = $"Clone 처리 시간: {sw.ElapsedMilliseconds}ms";
    pictureBox.Image = clonedBmp;
}

 

Sclae()

private void BtnScale_Click(object sender, EventArgs e)
{
    var sw = Stopwatch.StartNew();

    Bitmap scaledBmp = new Bitmap(sourceBmp.Width * 2, sourceBmp.Height * 2);
    using (Graphics g = Graphics.FromImage(scaledBmp))
    {
        g.InterpolationMode = InterpolationMode.HighQualityBicubic;
        g.DrawImage(sourceBmp, 0, 0, scaledBmp.Width, scaledBmp.Height);
    }

    pictureBox.SizeMode = PictureBoxSizeMode.AutoSize; // 실제 이미지 크기로 표시
    pictureBox.Image = scaledBmp;

    sw.Stop();
    lblResult.Text = $"Scale 처리 시간: {sw.ElapsedMilliseconds}ms (크기: {scaledBmp.Width}x{scaledBmp.Height})";
}

 

RotateFlip()

private void BtnRotate_Click(object sender, EventArgs e)
{
    var sw = Stopwatch.StartNew();

    Bitmap rotatedBmp = new Bitmap(sourceBmp);
    rotatedBmp.RotateFlip(RotateFlipType.Rotate90FlipNone);

    sw.Stop();
    lblResult.Text = $"Rotate 처리 시간: {sw.ElapsedMilliseconds}ms";
    pictureBox.Image = rotatedBmp;
    sourceBmp = rotatedBmp; // 회전된 이미지를 소스로 유지하여 계속 90도씩 회전
}

 

 

 

기존에는 바이트 하나하나를 변환하며 사용해야 했던 것들이 C#에서는 Bitmap 클래스의 메서들을
활용하여 정말 간단하고 빠르게 사용할 수 있습니다.

 

세번째로는 Bitmap의 메모리 관리에 대해서 알아보겠습니다.

 

C#에서는 가비지 컬렉터가 메모리 관리를 알아서 하는 것으로 알고 있지만
Bitmap 같은 비관리 소스 ( unmanaged resources )는 예외적으로 자동으로 수거하지 못합니다.

Bitmap 은 .NET의 관리코드와 Windows GDI+의 네이티브 리소스를 동시에 사용하기 때문에
.NET의 관리코드 자체는 가비지 컬렉터에서 수거 및 관리할 수 있지만 GDI+핸들과 같은 비관리 리소스는 처리할 수 없습니다.

그렇기 때문에 Bitmap 클래스를 사용할 때에는 명시적인 Dispose 혹은 using이 필수적입니다.

따라서 기존에 사용했던 코드를 다시 보게 되면

private void BtnTestGetPixel_Click(object sender, EventArgs e)
{
    Bitmap workingBmp = new Bitmap(sourceBmp);
    var sw = Stopwatch.StartNew();

    for (int x = 0; x < workingBmp.Width; x++)
    {
        for (int y = 0; y < workingBmp.Height; y++)
        {
            Color pixel = workingBmp.GetPixel(x, y);
            workingBmp.SetPixel(x, y, Color.FromArgb(255 - pixel.R, 255 - pixel.G, 255 - pixel.B));
        }
    }

    sw.Stop();
    lblResult !.Text = $"GetPixel/SetPixel 처리 시간: {sw.ElapsedMilliseconds}ms";
    pictureBox!.Image = workingBmp;
}

와 같이 workingBmp 를 사용했는데 이때 workingBmp를 새로운 객체로 만들고 dispose하지 않았기 때문에
메모리가 지속적으로 증가될 수 있습니다.

 

Dispose 하지 않은 경우

 

 

Dispose 한 경우

 

이 개념이 얼핏 생각없이 보면 당연하게 생각될 수 있지만
저는 사실 조금 더 생각해보니 이해가 안되는 부분이 있었는데,
정확히 어떤 이유 때문에 메모리가 늘어나게 되는지? 였습니다.

picturebox.Image = workingBmp가 처음 호출된 이후 다시 버튼클릭 이벤트가 발생하여 해당 메서드가 다시 호출될 때,
메모리가 늘어나기 때문에 저는 정말 무식하게 기존에 있던 이미지 위에 다른 이미지가 3차원처럼 쌓이면서 
즉, 가장 Picturebox의 Image의 첫번째 메모리 주소는 바뀌지 않고 해당 메모리에 계속해서 메모리가 쌓이는 것인 줄 알았습니다.

하지만 picturebox.Image는 새로운 workingBmp로 참조가 덮어씌워집니다.

즉 picturebox.Image에 Bitmap이 '쌓이는' 것이 아니라, 이전 Bitmap의 참조가 끊어지고 새로운 Bitmap을 참조하게 되는 것이죠. 그런데 여기서 이전 Bitmap을 dispose하지 않으면 참조만 끊어진 채 미리 만들어진 메모리공간에서 해제되지 않습니다.

따라서 메모리 누수를 방지하기 위해서는 새로운 Bitmap을 할당하기 전에 이전 Bitmap을 dispose 해주어야 합니다.

그래서 

if (pictureBox!.Image != sourceBmp)
{
    pictureBox.Image.Dispose();
}
pictureBox.Image = workingBmp;

와 같이 pictureBox.Image에 있는 이 전 Bitmap 을 dispose해준 뒤에 workingBmp라는 bitmap의 참조값을 줘야 하죠

728x90

'Program > C#' 카테고리의 다른 글

C# 병렬처리3 _ Mutex  (0) 2024.11.23
C# 병렬처리2 _ Monitor  (0) 2024.11.22
C# 병렬처리1 _ lock  (0) 2024.11.20
C# 얕은복사, 깊은복사  (1) 2024.11.14
C# 병렬처리1 _ Parallel  (0) 2024.11.11