스캔한 문서나 캡처 이미지, 쇼핑몰 상품 사진을 모아 쓰다 보면 주변에 흰 여백이 제각각 붙어 있어서 나란히 놓으면 크기가 들쭉날쭉해 보입니다. 하나씩 그림판으로 잘라 내기엔 양이 많을 때 쓰려고 만든 확장 메서드입니다. Bitmap 에 .TrimImage() 를 붙이면 내용이 있는 영역만 남기고 바깥 여백을 잘라 냅니다.

Imports System.Drawing.Imaging
Imports System.Runtime.CompilerServices

Public Module BitmapExtensions
    <Extension()>
    Public Function TrimImage(ByRef img As Bitmap) As Bitmap
        Dim threshhold = 250

        Dim topOffset = 0
        Dim bottomOffset = 0
        Dim leftOffset = 0
        Dim rightOffset = 0

        Dim x As Long, y As Long, GetColor As Color
        Dim foundColor = False
        ' 왼쪽 경계 찾기
        x = 1

        While x < img.Width AndAlso foundColor = False
            y = 1

            While y < img.Height AndAlso foundColor = False
                GetColor = img.GetPixel(x, y)
                If GetColor.R < threshhold OrElse GetColor.G < threshhold OrElse GetColor.B < threshhold Then foundColor = True
                y += 1
            End While
            leftOffset += 1
            x += 1
        End While

        foundColor = False
        ' 위쪽 경계 찾기
        y = 1

        While y < img.Height AndAlso foundColor = False
            x = 1

            While x < img.Width AndAlso foundColor = False
                GetColor = img.GetPixel(x, y)
                If GetColor.R < threshhold OrElse GetColor.G < threshhold OrElse GetColor.B < threshhold Then foundColor = True
                x += 1
            End While
            topOffset += 1
            y += 1
        End While

        foundColor = False
        ' 오른쪽 경계 찾기
        x = img.Width - 1

        While x >= 1 AndAlso foundColor = False
            y = 1

            While y < img.Height AndAlso foundColor = False
                GetColor = img.GetPixel(x, y)
                If GetColor.R < threshhold OrElse GetColor.G < threshhold OrElse GetColor.B < threshhold Then foundColor = True
                y += 1
            End While
            rightOffset += 1
            x -= 1
        End While

        foundColor = False
        ' 아래쪽 경계 찾기
        y = img.Height - 1

        While y >= 1 AndAlso foundColor = False
            x = 1

            While x < img.Width AndAlso foundColor = False
                GetColor = img.GetPixel(x, y)
                If GetColor.R < threshhold OrElse GetColor.G < threshhold OrElse GetColor.B < threshhold Then foundColor = True
                x += 1
            End While
            bottomOffset += 1
            y -= 1
        End While

        '' 여백을 뺀 크기로 새 이미지 만들기
        ''Dim newImg As Bitmap = New Bitmap(img.Width - leftOffset - rightOffset, img.Height - topOffset - bottomOffset)
        'Dim croppedBitmap As Bitmap = New Bitmap(img)
        'croppedBitmap = croppedBitmap.Clone(New Rectangle(leftOffset - 3, topOffset - 3, img.Width - leftOffset - rightOffset + 6, img.Height - topOffset - bottomOffset + 6), Drawing.Imaging.PixelFormat.DontCare)

        '' 새 비트맵의 Graphics 를 얻어 원본을 여백만큼 밀어서 그리기
        ''Dim g As Graphics = Graphics.FromImage(croppedBitmap)
        ''g.DrawImage(img, 1 - leftOffset, 1 - rightOffset)
        'Return croppedBitmap

        img = img.Clone(New Rectangle(leftOffset - 3, topOffset - 3, img.Width - leftOffset - rightOffset + 6, img.Height - topOffset - bottomOffset + 6), Drawing.Imaging.PixelFormat.DontCare)
        Return img
    End Function

End Module

경계를 찾는 방식

네 방향을 각각 따로 훑습니다. 왼쪽 경계라면 x 를 왼쪽부터 한 열씩 옮기면서 그 열의 픽셀을 위에서 아래로 검사하고, R·G·B 중 하나라도 250 보다 작은 픽셀이 나오면 "내용이 시작됐다"고 보고 멈춥니다. 그때까지 지나온 열 수가 leftOffset 입니다. 위·오른쪽·아래도 방향만 바꿔 같은 방식으로 셉니다.

기준값을 255(완전한 흰색)가 아닌 250 으로 둔 건 스캔이나 JPEG 압축 때문에 생기는 아주 연한 회색 노이즈를 여백으로 취급하려는 겁니다. 배경이 누렇게 스캔된 문서라면 이 값을 더 낮춰야 하고, 반대로 아주 연한 선까지 살려야 한다면 올리면 됩니다.

잘라 낼 때는 찾은 경계에서 사방으로 3픽셀씩 여유를 둡니다(가로세로 크기에 +6). 경계에 딱 맞춰 자르면 글자나 테두리가 잘린 것처럼 답답해 보여서 넣은 여백입니다. 코드 중간의 주석 처리된 부분은 새 비트맵을 만들어 그리는 방식으로 시도했던 흔적이고, 최종적으로는 Clone 에 잘라 낼 사각형을 주는 방식을 썼습니다.

사용 예와 주의점

Dim bmp As New Bitmap("scan.png")
bmp.TrimImage()
bmp.Save("scan_trim.png", ImageFormat.Png)

매개변수가 ByRef 라서 호출한 변수 자체가 잘린 이미지로 바뀝니다. 그래서 반환값을 받지 않아도 위처럼 bmp 가 바뀌어 있습니다. 원본을 남기려면 복사본에 호출하세요. 원래 비트맵은 교체된 뒤 따로 해제되지 않으니, 많은 파일을 처리할 때는 원본을 Dispose 하는 처리를 추가하는 게 좋습니다(위 사용 예는 설명용으로 덧붙인 것입니다).

내용이 이미지 가장자리에서 3픽셀 안쪽에 붙어 있으면 leftOffset - 3 같은 값이 음수가 되거나 사각형이 이미지 밖으로 나가서 Clone 에서 예외가 납니다. 완전히 흰 이미지도 마찬가지입니다. 실무에서 쓸 때는 사각형 좌표를 Math.Max(0, …) 로 묶고, 너비·높이가 이미지 크기를 넘지 않게 잘라 주는 처리를 넣어 두세요. 또 반복이 0 이 아니라 1 부터 시작해서 맨 첫 행과 첫 열은 검사하지 않습니다. 보통은 문제가 안 되지만 테두리선이 정확히 첫 픽셀에 있는 이미지라면 결과가 1픽셀 어긋날 수 있습니다.

GetPixel 은 픽셀 하나를 읽을 때마다 비용이 커서 큰 이미지에서는 느립니다. 여백이 넓은 수천 픽셀짜리 스캔본을 대량으로 처리한다면 LockBits 로 픽셀 데이터를 배열로 한 번에 읽어 검사하도록 바꾸면 체감 속도가 크게 달라집니다.