5.1. Image 객체¶
이미지 처리 알고리즘은 한 번에 한 픽셀씩 이미지를 가로질러 진행합니다. 각 위치에서 알고리즘은 단순한 작업을 수행합니다 – 값을 읽고, 임계값과 비교하고, 두 번째 이미지의 대응하는 픽셀과 결합하고, 결과를 다시 기록합니다. 이러한 단순한 픽셀별 결정을 프레임 전체에 걸쳐 반복하는 것이 바로 에지 검출, 블롭 추적, QR 코드 디코딩, 그리고 그 밖의 모든 고전적인 컴퓨터 비전 기법을 구성하는 토대입니다. 이 작업을 효율적으로 수행하려면 알고리즘은 각 픽셀이 메모리상 어디에 위치하는지, 각 픽셀의 값이 실제로 무엇을 의미하는지, 그리고 이미지의 어느 부분을 봐야 하는지를 알아야 합니다. image.Image 는 그 정보를 정리하는 객체입니다.
Vision Sensors는 csi.CSI.snapshot() 이 반환되는 시점에서 끝났습니다. 캡처된 프레임을 생성하기 위해 카메라 측 기계 장치가 한 작업은 이미 완료되었으며, 애플리케이션은 Image 를 손에 쥐고 그것으로 무엇을 해야 할지 알아야 합니다.
5.1.1. 버퍼와 그 속성¶
Image 안에는 RAM에 연속적으로 배치된 바이트 블록을 가리키는 포인터와, 세 가지 메타데이터를 담은 작은 헤더가 있습니다: 이미지의 픽셀 단위 너비, 픽셀 단위 높이, 그리고 바이트가 어떤 픽셀 포맷으로 되어 있는지입니다. 바이트는 픽셀 자체이며, 행 우선(row-major) 순서로 저장됩니다 – 맨 윗행의 모든 픽셀이 먼저 오고, 그다음 둘째 행의 모든 픽셀, 그렇게 맨 아래까지 이어집니다. 속성은 이를 어떻게 읽어야 하는지를 설명합니다.
너비와 높이는 단순한 정수 개수입니다. 픽셀 포맷은 더 흥미로운 속성인데, 각 픽셀이 몇 바이트를 차지하는지와 그 바이트들이 무엇을 인코딩하는지를 결정하기 때문입니다. 그레이스케일 이미지는 밝기 값을 담은 픽셀당 1바이트를 가집니다. RGB565 이미지는 16비트 워드에 빨강, 초록, 파랑 필드를 채워 넣은 픽셀당 2바이트를 가집니다. Bayer 이미지는 픽셀당 1바이트를 가지지만, 각 픽셀은 모자이크 내 위치에 따라 선택된 세 가지 색상 필터 중 하나를 통해 샘플링됩니다. Vision Sensors 에서 전체 목록을 열거했습니다. 여기서 중요한 것은 모든 Image 에 그러한 포맷 중 정확히 하나가 설정되며, 그 선택이 픽셀당 바이트 계산과 버퍼 내 단일 바이트의 의미를 좌우한다는 점입니다.
버퍼에 대한 포인터, 너비, 높이, 그리고 포맷이 있으면, 알고리즘이 원할 법한 다른 모든 속성은 짧은 계산으로 도출됩니다. 픽셀 (x, y) 가 시작되는 바이트는 버퍼 시작점으로부터 (y * width + x) * bytes_per_pixel 오프셋에 위치합니다. 전체 바이트 수는 width * height * bytes_per_pixel 입니다. 한 행 아래의 주소는 현재 행 시작점으로부터 정확히 width * bytes_per_pixel 바이트 뒤입니다. Image 는 세 가지 속성을 단순한 메서드 호출로 노출합니다 – width(), height(), format() – 그리고 size() 를 통해 파생된 size 도 제공합니다. 모듈 내 다른 곳의 메서드들은 이 값들을 사용해 오프셋 계산을 직접 수행하므로, 애플리케이션 코드가 그럴 일은 드뭅니다.
Image 는 연속적인 메모리 블록을 가리키는 작은 Python 래퍼입니다: 너비, 높이, 픽셀 포맷을 담은 헤더와 그 뒤를 잇는 픽셀 버퍼 자체로 이루어집니다.¶
5.1.2. 버퍼의 출처¶
이 장 전반에 걸친 기본 시나리오는 Vision Sensors에서 이미 다룬 것입니다: 캡처된 프레임이 snapshot 에서 도착하고, 바이트는 카메라의 프레임 버퍼에 놓여 있으며, 반환된 Image 가 그것을 가리킵니다. 이미지를 얻는 다른 세 가지 방법도 정기적으로 등장하는데, 각각은 버퍼가 어디에 위치하게 되는지에 대해 서로 다른 의미를 함축합니다.
파일에서 불러오는 것은 생성자에 경로를 전달하는 모양새입니다: image.Image("/sdcard/saved.jpg"). 모듈은 파일을 Python 힙에 새로 할당된 버퍼로 읽어 들입니다. BMP, PGM, PPM 파일은 읽어 들이는 과정에서 디코딩되며, 결과로 나온 Image 는 압축되지 않은 픽셀 포맷을 가집니다. JPEG와 PNG 파일은 압축된 상태로 유지됩니다 – Image 는 JPEG 또는 PNG 포맷을 가지며, 버퍼는 파일의 바이트 스트림을 사실상 변경 없이 담습니다. 압축된 이미지에 픽셀 단위 작업을 하려면, 애플리케이션은 먼저 to_rgb565() 또는 to_grayscale() 를 통해 변환하며, 압축 해제 – 그리고 그에 따른 힙 팽창, 즉 30 KB의 JPEG가 600 KB의 RGB565가 될 수 있는 – 가 실제로 일어나는 곳이 바로 그 변환입니다. 파일에서 불러오기는 개발 중에 가장 유용한데, 알고리즘을 스크립트와 함께 저장된 알려진 참조 프레임으로 테스트해야 할 때 그렇습니다.
처음부터 만드는 것은 캔버스 사례입니다: image.Image(320, 240, image.RGB565) 는 해당 포맷으로 그만큼의 바이트를 할당하고, 내용을 0으로 채우고, 래퍼를 돌려달라고 모듈에 요청합니다. 픽셀은 아직 아무 의미도 없습니다 – 모두 0입니다 – 그러나 이 빈 이미지는 반복적으로 등장하는 몇 가지 패턴의 일꾼입니다: 현재 프레임을 뺄 대상이 되는 참조 프레임, 그래픽 오버레이가 합성되는 캔버스, 채워진 후 마스크로 사용되는 이진 버퍼 등입니다.
ndarray로부터 구성하는 것은 반대 방향, 즉 임의의 수치 계산에서 다시 image 모듈로 다리를 놓습니다. float32 ulab.numpy.ndarray 를 생성자에 전달하면 ndarray와 차원이 일치하는 Image 가 생성됩니다 – 두 축의 (h, w) 형상은 그레이스케일 이미지가 되고, 세 축의 (h, w, 3) 형상은 RGB565가 됩니다 – 이때 float 값은 0.0 – 255.0 에서 정수 픽셀 범위로 스케일됩니다. 신경망 히트맵, 임의의 수치 배열, ml 또는 ulab 로 생성된 그 무엇이든 image 모듈의 그리기 및 검사 측에서 사용할 수 있는 것이 됩니다.
네 가지 소스 모두 같은 종류의 Image 를 돌려줍니다. 반환된 객체를 사용하는 코드는 그것이 어디서 왔는지 추적할 필요가 전혀 없습니다.
5.1.3. 바이트에 대한 두 가지 관점¶
대부분의 경우 애플리케이션 코드는 Image 를 타입이 지정된 이미지 객체 – 이름 붙은 메서드를 가진 것 – 로 다룹니다. 이야기의 나머지 절반은, 같은 객체가 bytes 인수를 받는 임의의 MicroPython API에 대해서는 평평한 바이트 시퀀스로도 투명하게 나타난다는 점입니다. 이 바이트는 버퍼의 복사본이 아니라 그것에 대한 직접적인 뷰입니다.
이러한 구성 덕분에 캡처된 프레임을 cam 밖으로 내보내는 것이 한 줄로 가능합니다. 해시를 계산하거나, 시리얼 포트로 보내거나, 네트워크 소켓으로 전달하는 것 – 이 중 어느 것도 별도의 “이미지를 바이트로 변환” 단계가 필요하지 않습니다:
import csi
import hashlib
csi0 = csi.CSI()
csi0.reset()
csi0.pixformat(csi.RGB565)
csi0.framesize(csi.QQVGA)
img = csi0.snapshot()
uart.write(img) # transmits the raw pixel bytes
hashlib.sha256(img) # hashes the same bytes
sock.send(img) # sends them over a socket
바이트형 뷰는 의도적으로 기본적으로 읽기 전용 입니다. 이미지 버퍼는 크고 때로는 이미징 스택의 여러 계층 사이에서 공유되므로, 호출 스택 깊숙한 어딘가의 무심한 buf[0] = 0 에 그것을 조용히 손상시킬 권한을 주는 것은 노출된 채로 두기에는 너무 날카로운 칼날입니다. 읽기-쓰기 바이트 단위 접근이 애플리케이션에 실제로 필요할 때 – 예를 들어 알려진 오프셋에 캘리브레이션 값을 기록하는 경우 – bytearray() 는 같은 메모리에 대한 별도의, 명시적으로 읽기-쓰기인 뷰를 반환하여 호출 지점에서 그 의도를 드러냅니다.
5.1.4. 버퍼가 위치하는 곳¶
픽셀 버퍼는 RAM의 어디에 놓이는지가 중요할 만큼 큽니다. QQVGA RGB565 프레임은 160 × 120 × 2 = 38,400 바이트이고, VGA RGB565 프레임은 614,400 바이트이며, 신경망 분류기가 소비할 수 있는 224 × 224 RGB565 입력은 약 100 KB입니다. 가장 작은 cam의 Python 힙은 런타임이 부팅되고 나면 고작 수십 킬로바이트일 수 있습니다. 한두 프레임 이상의 이미지 데이터를 힙에 보관하면 그 밖의 모든 것을 밀어낼 것입니다.
해결책은 이미지 버퍼가 대부분 Python 힙에 위치하지 않는다는 것입니다. 이들은 Vision Sensors 에서 프레임 버퍼 로 소개한 RAM의 전용 영역에 위치합니다 – 카메라 DMA가 캡처된 프레임을 기록하고 IDE 미리보기가 완성된 프레임을 읽어 내는 바로 그 메모리입니다. Image 에 대한 대부분의 연산은 소스를 제자리에서(in place) 수정합니다: 알고리즘이 픽셀을 읽고, 결정하고, 새 값을 다시 기록하며, 별도의 결과 이미지는 할당되지 않습니다. 별도의 결과를 실제로 생성하는 연산들 – 포맷 변환과 그 밖의 소수 – 은 copy_to_fb 키워드 인수를 통해 그 결과를 프레임 버퍼에 배치하도록 요청할 수 있습니다. copy_to_fb=True 는 두 가지를 동시에 합니다: 결과 이미지를 힙이 아니라 프레임 버퍼에 넣고(힙 압박을 회피), 그 결과를 IDE 미리보기가 표시할 다음 프레임으로 만듭니다. 파이프라인의 마지막 단계에 copy_to_fb=True 를 덧붙이고, 결과가 화면에 나타나는 것을 지켜보며, 거기서부터 반복하는 것은 이미지 처리에서 가장 유용한 디버깅 관용구 중 하나입니다.
레이블이 붙은 버퍼를 담은 래퍼, 그것을 존재하게 만드는 네 가지 방법, 그 바이트에 대한 두 가지 관점, 그리고 새 것이 어디에 놓일지 결정하는 스위치를 갖춘 Image 는 더 이상 미스터리가 아닙니다. 남은 기초적 질문들 – 픽셀 위치가 어떻게 명명되는지, 각 픽셀이 실제로 무엇을 담는지, 연산을 이미지의 한 부분으로 한정하는 방법 – 은 이 위에 세워집니다.