ESP32에서 OpenCV 흉내내기
ESP32 마이크로컨트롤러는 WiFi/Bluetooth를 내장한 저전력·저가격 칩으로, IoT 카메라 프로젝트에 널리 사용된다. 하지만 제한된 메모리(SRAM 512KB)와 연산 능력 때문에 OpenCV 같은 본격적인 컴퓨터 비전 라이브러리를 포팅하기 어렵다. 대부분의 ESP32 카메라 프로젝트는 JPEG 스트리밍이나 단순 캡처에 그치며, 엣지에서 실시간 이미지 처리를 수행하려면 외부 서버나 PC로 전송 후 처리하는 방식을 택해야 했다.
그런데 최근 grayskull 오픈 소스를 보고 에지에서도 이미지 프로세싱이 가능하겠다는 생각이 들어서 작업을 한번 해봤다.
Grayskull: 임베디드용 컴퓨터 비전 라이브러리
Grayskull은 마이크로컨트롤러를 타겟으로 설계된 경량 컴퓨터 비전 라이브러리다. 주요 특징은 다음과 같다.
- 단일 헤더 파일 (
grayskull.h) – 의존성 없음 - C99 표준 – Arduino 환경 포함 대부분의 임베디드 플랫폼 지원
- 동적 메모리 할당 불필요 – 사용자가 버퍼 관리
- 정수 연산 기반 – FPU 없는 환경에 최적화
- 1KLOC 미만 – 코드 크기 약 34KB
제공하는 주요 알고리즘:
- Blur (박스 필터)
- Threshold (고정 임계값, Otsu 자동 임계값, 적응형 임계값)
- Morphology (침식, 팽창)
- Edge Detection (Sobel)
- Connected Components
- FAST/ORB 특징점 검출
- LBP 캐스케이드 (얼굴/객체 검출)
하드웨어 구성
테스트에 사용한 보드는 DFRobot FireBeetle 2 ESP32-S3 와 OV2640 카메라이다.
주요 스펙:
- 칩셋: ESP32-S3 (Xtensa LX7 dual-core @ 240MHz)
- 메모리: 16MB Flash, 8MB PSRAM
- 카메라: OV2640 (2MP, 1600×1200 최대 해상도)
- 전원 관리: AXP313A (Li-ion 배터리 충전, 카메라 독립 전원)
- 통신: WiFi 4 (802.11b/g/n), Bluetooth 5 LE
PSRAM 8MB가 핵심이다. 카메라 프레임 버퍼와 이미지 처리 중간 버퍼를 PSRAM에 할당하면 내부 SRAM을 아낄 수 있다.
구현 방법
1. 카메라 초기화
ESP32-S3의 esp_camera 라이브러리를 사용해 OV2640를 초기화한다. 포맷은 JPEG를 기본으로 하되, Grayskull 처리 요청 시 런타임 변환을 수행한다.
camera_config_t config; config.pixel_format = PIXFORMAT_JPEG; // 기본 JPEG config.frame_size = FRAMESIZE_HD; // 1280×720 config.fb_location = CAMERA_FB_IN_PSRAM; // PSRAM 사용 config.fb_count = 2; esp_camera_init(&config);
기본적으로 HD(1280 x 720)에서 잘된다. 그렇지만 메모리 부족 시 해상도를 VGA(640×480) 또는 QVGA(320×240)로 낮출수 있다.
2. JPEG → 그레이스케일 변환
Grayskull은 그레이스케일 이미지(uint8_t 1채널)만 처리한다. JPEG 프레임을 처리하려면 다음 단계가 필요하다.
- JPEG 디코딩:
fmt2rgb888()로 RGB888 버퍼 생성 - 그레이스케일 변환: RGB를 가중 평균으로 변환 (Y = 0.299R + 0.587G + 0.114B)
bool convertFrameToGrayscale(const camera_fb_t* fb, gs_image& gray) {
size_t rgbSize = fb->width * fb->height * 3;
uint8_t* rgbBuffer = (uint8_t*)ps_malloc(rgbSize);
if (!fmt2rgb888(fb->buf, fb->len, fb->format, rgbBuffer)) {
free(rgbBuffer);
return false;
}
gray = gs_alloc(fb->width, fb->height);
for (unsigned i = 0; i < fb->width * fb->height; i++) {
uint8_t r = rgbBuffer[i * 3];
uint8_t g = rgbBuffer[i * 3 + 1];
uint8_t b = rgbBuffer[i * 3 + 2];
gray.data[i] = (299 * r + 587 * g + 114 * b) / 1000;
}
free(rgbBuffer);
return true;
}
VGA 기준 RGB888 버퍼는 약 900KB이므로 PSRAM(ps_malloc) 사용이 필수다.
3. Grayskull 처리
변환된 그레이스케일 이미지에 원하는 알고리즘을 적용한다.
// 블러 struct gs_image blurred = gs_alloc(img.w, img.h); gs_blur(blurred, img, radius); // 임계값 struct gs_image binary = gs_alloc(img.w, img.h); gs_copy(binary, img); gs_threshold(binary, threshold); // Otsu 자동 임계값 uint8_t otsu = gs_otsu_threshold(img); gs_threshold(binary, otsu); // 엣지 검출 struct gs_image edges = gs_alloc(img.w, img.h); gs_sobel(edges, img);
각 함수는 인플레이스 또는 목적지 버퍼를 요구한다. 메모리 해제는 gs_free()로 수행한다.
4. 결과 JPEG 재인코딩
처리 결과를 네트워크로 전송하려면 다시 JPEG로 압축한다. fmt2jpg()는 그레이스케일을 JPEG로 변환한다.
uint8_t* jpgBuffer = nullptr;
size_t jpgLength = 0;
bool success = fmt2jpg(processedImage.data,
processedImage.w * processedImage.h,
processedImage.w,
processedImage.h,
PIXFORMAT_GRAYSCALE,
quality,
&jpgBuffer,
&jpgLength);
if (success) {
http.POST(jpgBuffer, jpgLength);
free(jpgBuffer);
}
JPEG 품질(0~63, 낮을수록 고품질)을 조절해 전송 속도와 이미지 품질 간 균형을 맞춘다.
5. 이미지 저장
촬영후 처리한 이미지는 http를 통해 서버로 전송했고, 서버는 FastAPI로 구현했으며, 타임스탬프 기반 파일명으로 이미지를 저장한다.
@app.post("/upload")
async def upload_image(request: Request):
image_data = await request.body()
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
filename = f"capture_{timestamp}.jpg"
with open(f"data/{filename}", "wb") as f:
f.write(image_data)
return {"status": "success", "filename": filename}
처리 결과
원본 이미지와 각 알고리즘 적용 결과는 다음과 같다.
원본
Blur
Threshold
Otsu
Edges
처리 시간은 VGA 기준 블러 약 200ms, Sobel 약 150ms 정도다. 해상도와 알고리즘 복잡도에 비례한다.
메모리 관리
ESP32-S3의 메모리 구성은 다음과 같다.
[System] 초기 사용 가능한 메모리: 304696 bytes [System] 최대 연속 메모리 블록: 249844 bytes [System] PSRAM 크기: 8388608 bytes [System] 사용 가능한 PSRAM: 8386096 bytes
PSRAM 없이는 HD 해상도 처리가 불가능하다. PSRAM 할당은 ps_malloc()을 사용하며, 실패 시 즉시 복구 로직을 실행해야 한다.
uint8_t* buffer = (uint8_t*)ps_malloc(size);
if (!buffer) {
Serial.println("PSRAM allocation failed");
return false;
}
처리 완료 후에는 모든 임시 버퍼를 명시적으로 해제한다.
cleanup: if (fb) esp_camera_fb_return(fb); if (processedImage.data) gs_free(processedImage); if (jpgBuffer) free(jpgBuffer);
제약사항
- JPEG 디코딩 오버헤드: VGA 기준 RGB 변환에 약 100~150ms 소요
- 메모리 제약: FHD(1920×1080) 이상은 PSRAM 8MB로도 버거움
- 처리 속도: 실시간 비디오 처리(30fps)는 불가능, 정적 이미지 분석에 적합
- 알고리즘 제한: CNN 같은 딥러닝 모델은 불가능 (ESP32-S3의 AI 가속기는 별도 학습 필요)
결론
Grayskull은 ESP32에서 OpenCV의 기본 기능을 구현할 수 있게 한다. 제한된 메모리와 처리 능력 내에서 블러, 임계값, 엣지 검출 같은 전통적인 컴퓨터 비전 알고리즘을 실행 가능하며, 클라우드 의존 없이 엣지 디바이스에서 이미지 분석을 수행할 수 있을것 같다.
참고 자료
