5.33. ImageIO 스트림¶
save() 와 to_jpeg() 는 단일 프레임 I/O 사례를 다룹니다. 즉 애플리케이션이 프레임 하나를 캡처하고, 인코딩한 다음, 어딘가로 내보내는 경우입니다. 또 다른 부류의 애플리케이션은 시퀀스 사례를 필요로 합니다. 즉 자연스러운 캡처 속도로 여러 프레임을 연속해서 기록하고, 나중에 다시 불러올 수 있는 곳에 저장하며, 올바른 속도로 재생하는 것입니다. 학습 데이터 수집 스크립트는 머신 러닝 파이프라인을 위해 수백 개의 예제 프레임을 캡처하고, 검사 스테이션 로그는 추적성을 위해 캡처된 모든 부품을 기록하며, 개발 스크립트는 저장된 시퀀스를 다시 재생하여 이전에 라이브로 캡처한 데이터에 대해 새로운 알고리즘을 테스트합니다.
ImageIO 클래스는 image 모듈의 레코더/플레이어입니다. 하나의 스트림은 Image 프레임의 시퀀스를 담는데, 이 프레임들은 크기와 픽셀 형식이 서로 다를 수 있으며, 각 프레임의 프레임 간 간격도 함께 저장되므로 재생 시 원래의 프레임 레이트를 재현할 수 있습니다. 두 가지 백업 저장소를 사용할 수 있습니다. 파일 시스템상의 파일이거나 RAM의 고정 크기 버퍼입니다.
5.33.1. 두 가지 백업 저장소¶
파일 스트림 은 전원 주기에 걸쳐 녹화 내용을 유지하며, 그 크기는 이를 뒷받침하는 저장소에 의해서만 제한됩니다. 파일은 16바이트 매직 헤더 OMV IMG STR Vx.y 로 시작하고 그 뒤로 프레임당 하나의 청크가 이어집니다. 현재의 라이터는 V2.0 을 생성하며, 리더는 하위 호환성을 위해 V1.0 및 V1.1 파일도 여전히 받아들입니다. 파일 경로는 생성자 인자이며, 모드는 파일 열기 모드입니다 (기존 스트림을 읽으려면 'r' , 잘라내고 새로 쓰려면 'w' ).
# Recording to /sdcard/run.bin
stream = image.ImageIO("/sdcard/run.bin", "w")
for _ in range(120):
img = csi0.snapshot()
stream.write(img)
stream.close()
메모리 스트림 은 생성 시 할당되는 RAM 버퍼 안에 존재합니다. 생성자는 경로 대신 (w, h, pixformat) 3-튜플을 받으며, mode 인자는 사전 할당되는 프레임 슬롯 개수 가 됩니다. 버퍼는 제공된 크기에서 해당 개수만큼의 프레임에 딱 맞게 크기가 정해지며, 일단 할당되면 늘어날 수 없습니다. 마지막 슬롯을 넘어 쓰면 EOFError 가 발생하고, 슬롯당 버퍼보다 큰 프레임을 쓰면 ValueError 가 발생합니다. 메모리 스트림은 애플리케이션이 파일 시스템을 거치지 않고 다운스트림 단계로 녹화물 을 넘겨야 할 때 적합한 도구입니다 (예를 들어 트리거 후 재생 패턴을 위한 최근 프레임의 짧은 링 버퍼).
# Pre-allocate space for 32 QVGA RGB565 frames in RAM
stream = image.ImageIO((320, 240, image.RGB565), 32)
for _ in range(32):
stream.write(csi0.snapshot())
압축 픽셀 형식(image.JPEG, image.PNG)의 경우 슬롯당 크기는 픽셀당 2비트로 추정됩니다. 인코딩된 프레임이 추정치보다 크면 쓰기 시점에 ValueError 가 발생하므로, 고화질 JPEG를 저장하려는 애플리케이션은 슬롯 개수를 넉넉하게 할당하거나 먼저 더 낮은 화질로 인코딩해야 합니다.
type() 는 image.ImageIO.FILE_STREAM 또는 image.ImageIO.MEMORY_STREAM 을 반환하므로, 다운스트림 코드는 어떤 백업 저장소가 주어지든 그에 맞게 동작할 수 있습니다.
5.33.2. 녹화¶
write() 는 캡처한 Image 를 파일 스트림에 추가하거나(메모리 스트림의 경우 현재 슬롯에 저장하고) 오프셋을 하나 앞으로 진행시킵니다. 같은 호출이 마지막 쓰기 이후의 프레임 간 간격 도 기록하므로, 재생 측에서는 프레임 사이에 적절한 시간만큼 멈출 수 있고 녹화물의 자연스러운 프레임 레이트가 보존됩니다.
단일 파일 스트림 내에서 이질적인 프레임이 허용됩니다. 하나의 녹화물은 RGB565 캡처, 그레이스케일 크롭, JPEG로 인코딩된 썸네일을 자유롭게 혼합할 수 있으며, 리더는 각각을 원래의 크기와 형식으로 디코딩합니다. 메모리 스트림은 동질적입니다 (모든 슬롯이 생성자에서 제공된 (w, h, pixformat) 을 공유함). 따라서 메모리 녹화물은 하나의 프레임 구성으로 제한됩니다.
write() 는 스트림 객체를 반환하므로 호출을 연쇄할 수 있습니다. 파일 스트림의 끝이 아닌 오프셋에 쓰면 파일의 나머지 부분이 잘립니다. 저장된 시퀀스를 편집할 때는 유용하지만, 앞선 seek() 로 인해 다음 쓰기 위치가 의도치 않게 옮겨졌다면 위험합니다.
sync() 는 파일 스트림의 경우 대기 중인 쓰기를 디스크로 플러시합니다 (메모리 스트림에서는 아무 동작도 하지 않음). 녹화가 장시간 진행될 때는 파일이 닫히기 전에 캠이 재부팅되어 녹화물의 끝부분을 잃지 않도록 주기적으로 호출해야 합니다. ImageIO 가 범위를 벗어나면 소멸자가 스트림을 자동으로 닫지만, 명시적으로 close() 를 호출하는 것이 올바른 습관입니다.
5.33.3. 재생¶
read() 는 현재 오프셋의 프레임을 읽고, 오프셋을 진행시키며, 새로운 Image 를 반환합니다. copy_to_fb=True (기본값)일 때 수신된 프레임은 프레임 버퍼에 남아 있으므로 반환된 이미지는 IDE 프리뷰를 통해 그릴 수 있습니다. copy_to_fb=False 일 때는 프레임이 MicroPython 힙에 놓입니다.
# Loop a recorded stream at its natural frame rate
stream = image.ImageIO("/sdcard/run.bin", "r")
while True:
img = stream.read()
# img is now in the frame buffer; the IDE shows it
# and the script can run any analysis it likes
두 개의 키워드가 재생 동작을 제어합니다. loop=True (파일 스트림의 기본값)는 녹화물의 끝에 도달하면 읽기 포인터를 처음으로 되감으므로 호출이 결코 None 을 반환하지 않습니다. loop=False 는 녹화물이 소진되면 None 을 반환하여 호출자의 루프를 종료시킵니다. pause=True (기본값)는 쓰기 시점에 기록된 프레임 간 간격이 경과할 때까지 호출을 차단하므로 재생 프레임 레이트가 원래의 캡처 프레임 레이트와 일치합니다. pause=False 는 즉시 반환하는데, 원래의 타이밍을 지키지 않고 녹화물을 최대한 빠르게 처리하려는 분석 파이프라인에 유용합니다.
메모리 스트림에서도 동일한 루프 패턴이 작동하지만 loop 는 무시됩니다. 메모리 스트림의 끝을 넘어 읽으면 EOFError 가 발생합니다. 메모리 링에서 권장되는 패턴은 되감기를 원할 때 seek() 로 명시적으로 0으로 돌아가는 것입니다.
5.33.5. 호스트에서 재생 가능한 녹화물¶
ImageIO 스트림은 녹화물을 캠에서 재생하려는 경우에 적합한 도구입니다. 캡처된 모든 프레임을 원래의 픽셀 형식으로 보존하고, 프레임 간 간격을 정확히 기록하며, 다운스트림 스크립트가 손실 없이 프레임 단위로 진행하고, 탐색하고, 재분석할 수 있습니다. 그러나 녹화물을 호스트 (워크스테이션, 휴대폰, 웹 플레이어)에서 재생해야 할 때는 적합한 도구가 아닙니다. 호스트는 OpenMV 디스크상의 매직 헤더 형식이 아니라 표준 비디오 컨테이너를 기대합니다.
두 개의 별도 모듈이 호스트 재생 사례를 다룹니다. mjpeg 모듈은 Motion JPEG를 녹화합니다. 즉 JPEG로 압축된 프레임 시퀀스를 하나의 AVI 스타일 컨테이너에 담은 것으로, VLC, QuickTime, ffmpeg, 그리고 표준 웹 비디오 태그가 모두 바로 재생합니다. gif 모듈은 애니메이션 GIF를 녹화합니다. 즉 명시적인 프레임별 지연 시간을 가진 비압축(또는 팔레트 압축) 프레임 시퀀스로, 애니메이션 GIF를 처리하는 어떤 웹 브라우저나 이미지 뷰어에서도 재생됩니다.
mjpeg 모듈은 긴 녹화에 자연스러운 선택입니다. JPEG 압축은 파일 크기를 관리 가능한 수준으로 유지하므로 (설정된 화질에서의 to_jpeg() 에 견줄 만한 크기로 프레임마다 이어집니다), 긴 캡처 세션도 SD 카드의 용량 범위 안에 머무릅니다. 사용법은 ImageIO 녹화와 매우 유사합니다:
import mjpeg
m = mjpeg.Mjpeg("/sdcard/run.mjpeg")
while running:
m.add_frame(csi0.snapshot(), quality=85)
m.close()
mjpeg.Mjpeg 는 다른 image 메서드가 받는 것과 동일한 그리기 스타일의 위치 인자 및 스케일 키워드를 받으므로, 녹화 시 프레임마다 입력되는 내용을 스케일링하거나, 크롭하거나, 팔레트 매핑할 수 있습니다. 생성자의 width 와 height 인자는 기본적으로 메인 프레임 버퍼의 크기로 설정되며 출력 해상도를 고정합니다. 추가되는 모든 프레임은 (가로세로 비율을 유지하며) 거기에 맞게 스케일링됩니다. sync() 는 긴 녹화 중에 파일을 디스크로 플러시하고, close() 는 컨테이너를 마무리합니다. 깔끔하게 닫히지 않은 Motion JPEG 파일은 재생할 수 없으므로 이 습관이 중요합니다.
gif 모듈은 비기술적인 시청자와 그대로 공유하는 짧은 녹화에 자연스러운 선택입니다. 데모를 위해 캡처한 몇 초간의 동작, 문서를 위한 애니메이션 삽화, 채팅 메시지에 삽입된 이벤트 클립 등이 그 예입니다. GIF 프레임은 비압축(또는 7비트 색상 깊이로 팔레트 압축)으로 저장되므로 초당 파일 크기가 Motion JPEG보다 훨씬 커서 몇 초보다 긴 녹화에는 이 형식이 적합하지 않지만, 결과물은 어떤 브라우저에도 바로 넣을 수 있습니다:
import gif
g = gif.Gif("/sdcard/clip.gif")
while running:
g.add_frame(csi0.snapshot(), delay=10)
g.close()
add_frame() 의 delay 인자는 프레임별 표시 시간을 센티초 단위로 나타냅니다 (10 은 프레임당 100ms, 즉 10fps). 이는 표준 GIF 재생 제어입니다. 생성자의 loop 키워드는 결과 클립이 뷰어에서 자동으로 반복될지를 설정합니다 (기본값은 True 이며, 이는 통상적인 “애니메이션 GIF” 기대치에 부합합니다).
세 가지 녹화 경로는 그 사이에서 흔한 사례들을 모두 다룹니다. 캠 내 재처리를 위한 ImageIO, 호스트에서 재생 가능한 긴 녹화를 위한 Motion JPEG, 호스트에서 재생 가능한 짧은 클립을 위한 애니메이션 GIF입니다. 이들 사이의 선택은 결국 누가 녹화물을 재생하는가 로 귀결됩니다. 캠 자체에서 실행되는 다운스트림 단계는 ImageIO를 읽고, 호스트 워크스테이션이나 웹 뷰어는 MJPEG 또는 GIF를 읽습니다.
5.33.6. 트리거 후 재생 패턴¶
유용한 패턴 하나는 메모리 스트림과 트리거 조건을 결합합니다. 캠은 count 슬롯짜리 메모리 링 버퍼에 계속 녹화하며, 한 바퀴 돌 때마다 가장 오래된 슬롯을 덮어씁니다. 트리거 조건이 발동하면 (블롭이 프레임에 들어오거나, 모션 이벤트가 임계값을 초과하거나, 버튼이 눌리는 경우) 애플리케이션은 링의 내용, 즉 가장 최근의 count 프레임을 스냅샷으로 찍어 SD 카드의 파일 스트림에 씁니다. 그 결과는 캠이 실제로 알아챈 이벤트 이후 의 몇 초만이 아니라 이전 의 몇 초까지 캡처하는 프리트리거 녹화물 입니다. 후자는 순진한 “트리거 시 캡처” 레코더의 전형적인 한계입니다.
스트림 클래스를 손에 넣으면 구현은 간단합니다. 고정 크기 메모리 스트림이 링 역할을 하고 (오프셋이 슬롯 개수에 도달하면 seek() 로 명시적으로 0으로 돌아감), 메인 루프는 매 반복마다 거기에 캡처하며, 트리거 핸들러는 메모리 스트림을 프레임 단위로 읽어내 트리거 타임스탬프로 이름 지은 파일 스트림에 각각 씁니다.