설명
이 블로그 포스트의
- 커버 이미지: Google Nano Banana 2를 기반으로 생성되었으며, 저작권이 없습니다.
- 프로젝트 소스 코드: GitHub에 오픈 소스로 공개되었습니다. PPOCRv5-Android에서 확인하실 수 있습니다.
고지 사항:
필자(Fleey)는 AI 분야 종사자가 아니며, 순수하게 흥미로 인해 작성했습니다. 글에 누락이나 오류가 있더라도 독자 여러분의 너그러운 양해와 신속한 지적 부탁드립니다!
서론
2024년, Google은 TensorFlow Lite의 이름을 LiteRT로 변경했습니다. 이는 단순한 브랜드 리브랜딩이 아니라, 온디바이스 AI가 ‘모바일 우선’에서 ‘엣지 우선’으로 패러다임이 전환되었음을 상징합니다1. 이러한 배경 속에서 OCR(광학 문자 인식)은 가장 실용 가치가 높은 온디바이스 AI 애플리케이션 중 하나로서 조용한 혁명을 겪고 있습니다.
바이두(Baidu)의 PaddleOCR 팀은 2025년에 간체 중국어, 번체 중국어, 영어, 일본어 등 다국어를 지원하는 통합 OCR 모델인 PP-OCRv5를 발표했습니다2. 모바일 버전은 약 70MB에 불과하지만, 단일 모델에서 18,383개의 문자를 인식할 수 있습니다. 이 숫자 뒤에는 검출(Detection)과 인식(Recognition)이라는 두 개의 딥러닝 신경망의 협업이 숨어 있습니다.
하지만 문제는 PP-OCRv5가 PaddlePaddle 프레임워크를 기반으로 훈련된 반면, Android 기기에서 가장 성숙한 추론 엔진은 LiteRT라는 점입니다. 이 간극을 어떻게 메울 수 있을까요?
모델 변환부터 시작하여 온디바이스 OCR의 엔지니어링 실무를 단계별로 파헤쳐 보겠습니다.
flowchart TB subgraph E2E["엔드 투 엔드 OCR 프로세스"] direction TB
subgraph Input["입력"] IMG[원본 이미지<br/>임의 크기] end
subgraph Detection["텍스트 검출 - DBNet"] DET_PRE[전처리<br/>Resize 640x640<br/>ImageNet Normalize] DET_INF[DBNet 추론<br/>~45ms GPU] DET_POST[후처리<br/>이진화 - 윤곽선 - 회전 사각형] end
subgraph Recognition["텍스트 인식 - SVTRv2"] REC_CROP[투영 변환 크롭<br/>48xW 가변 너비] REC_INF[SVTRv2 추론<br/>~15ms/라인 GPU] REC_CTC[CTC 디코딩<br/>중복 병합 + 공백 제거] end
subgraph Output["출력"] RES[OCR 결과<br/>텍스트 + 신뢰도 + 위치] end end
IMG --> DET_PRE --> DET_INF --> DET_POST DET_POST -->|N개의 텍스트 박스| REC_CROP REC_CROP --> REC_INF --> REC_CTC --> RES모델 변환: PaddlePaddle에서 TFLite까지의 긴 여정
딥러닝 프레임워크의 파편화는 업계의 고질적인 문제입니다. PyTorch, TensorFlow, PaddlePaddle, ONNX 등 각 프레임워크는 자신만의 모델 형식과 연산자 구현 방식을 가지고 있습니다. ONNX(Open Neural Network Exchange)가 공통 중간 표현이 되려 노력하고 있지만, 현실은 이상보다 훨씬 가혹합니다.
PP-OCRv5의 모델 변환 경로는 다음과 같습니다.
flowchart LR subgraph PaddlePaddle["PaddlePaddle Framework"] PM[inference.json<br/>inference.pdiparams] end
subgraph ONNX["ONNX Intermediate"] OM[model.onnx<br/>opset 14] end
subgraph Optimization["Graph Optimization"] GS[onnx-graphsurgeon<br/>연산자 분해] end
subgraph TFLite["LiteRT Format"] TM[model.tflite<br/>FP16 Quantized] end
PM -->|paddle2onnx| OM OM -->|HardSigmoid 분해<br/>Resize 모드 수정| GS GS -->|onnx2tf| TM이 경로는 간단해 보이지만, 실제로는 함정이 숨어 있습니다.
첫 번째 난관: paddle2onnx의 연산자 호환성
paddle2onnx는 PaddlePaddle에서 공식 제공하는 모델 변환 도구입니다. 이론적으로는 PaddlePaddle 모델을 ONNX 형식으로 변환할 수 있습니다. 하지만 PP-OCRv5는 ONNX에서 일대일로 매핑되지 않는 몇 가지 특수 연산자를 사용합니다.
paddle2onnx --model_dir PP-OCRv5_mobile_det \ --model_filename inference.json \ --params_filename inference.pdiparams \ --save_file ocr_det_v5.onnx \ --opset_version 14여기서 중요한 디테일이 하나 있습니다. PP-OCRv5의 모델 파일 이름은 전통적인 inference.pdmodel이 아닌 inference.json입니다. 이는 PaddlePaddle 최신 버전의 모델 형식 변화로, 많은 개발자가 여기서 시행착오를 겪습니다3.
두 번째 난관: HardSigmoid와 GPU 호환성
변환된 ONNX 모델에는 HardSigmoid 연산자가 포함되어 있습니다. 이 연산자는 수학적으로 다음과 같이 정의됩니다.
여기서 , 입니다.
문제는 LiteRT의 GPU Delegate가 HardSigmoid를 지원하지 않는다는 점입니다. 모델에 지원되지 않는 연산자가 포함되어 있으면 GPU Delegate는 전체 서브그래프를 CPU에서 실행하도록 폴백(Fallback)하며, 이는 심각한 성능 저하를 초래합니다.
해결책은 HardSigmoid를 기본 연산자로 분해하는 것입니다. onnx-graphsurgeon 라이브러리를 사용하면 계산 그래프 수준에서 수술을 진행할 수 있습니다.
import onnx_graphsurgeon as gsimport numpy as np
def decompose_hardsigmoid(graph: gs.Graph) -> gs.Graph: """ HardSigmoid를 GPU 친화적인 기본 연산자로 분해 HardSigmoid(x) = max(0, min(1, alpha*x + beta)) 분해 결과: Mul -> Add -> Clip """ for node in graph.nodes: if node.op == "HardSigmoid": # HardSigmoid 파라미터 가져오기 alpha = node.attrs.get("alpha", 0.2) beta = node.attrs.get("beta", 0.5)
input_tensor = node.inputs[0] output_tensor = node.outputs[0]
# 상수 텐서 생성 alpha_const = gs.Constant( name=f"{node.name}_alpha", values=np.array([alpha], dtype=np.float32) ) beta_const = gs.Constant( name=f"{node.name}_beta", values=np.array([beta], dtype=np.float32) )
# 중간 변수 생성 mul_out = gs.Variable(name=f"{node.name}_mul_out") add_out = gs.Variable(name=f"{node.name}_add_out")
# 분해된 서브그래프 구축: x -> Mul(alpha) -> Add(beta) -> Clip(0,1) mul_node = gs.Node( op="Mul", inputs=[input_tensor, alpha_const], outputs=[mul_out] ) add_node = gs.Node( op="Add", inputs=[mul_out, beta_const], outputs=[add_out] ) clip_node = gs.Node( op="Clip", inputs=[add_out], outputs=[output_tensor], attrs={"min": 0.0, "max": 1.0} )
# 기존 노드 교체 graph.nodes.remove(node) graph.nodes.extend([mul_node, add_node, clip_node])
graph.cleanup().toposort() return graph이 분해의 핵심은 Mul, Add, Clip이 모두 LiteRT GPU Delegate에서 완벽하게 지원되는 연산자라는 점입니다. 분해 후에는 전체 서브그래프가 GPU에서 연속적으로 실행될 수 있어 CPU-GPU 데이터 전송 오버헤드를 피할 수 있습니다.
TIP
왜 모델 훈련 코드를 직접 수정하지 않을까요? HardSigmoid는 훈련 시 그래디언트 계산 방식이 Clip과 다르기 때문입니다. 분해는 추론 단계에서만 수행하여 훈련 시의 수치적 안정성을 유지해야 합니다.
세 번째 난관: Resize 연산자의 좌표 변환 모드
ONNX의 Resize 연산자에는 출력 좌표를 입력 좌표에 어떻게 매핑할지 결정하는 coordinate_transformation_mode 속성이 있습니다. PP-OCRv5는 half_pixel 모드를 사용하지만, LiteRT GPU Delegate는 이 모드에 대한 지원이 제한적입니다.
이를 asymmetric 모드로 변경하면 더 나은 GPU 호환성을 얻을 수 있습니다.
for node in graph.nodes: if node.op == "Resize": node.attrs["coordinate_transformation_mode"] = "asymmetric"WARNING
이러한 수정은 미세한 수치 차이를 발생시킬 수 있습니다. 실제 테스트 결과, OCR 정확도에 미치는 영향은 무시할 수 있는 수준이었으나, 다른 작업에서는 신중한 평가가 필요할 수 있습니다.
마지막 단계: onnx2tf와 FP16 양자화
onnx2tf는 ONNX 모델을 TFLite 형식으로 변환하는 도구입니다. FP16(반정밀도 부동 소수점) 양자화는 모바일 배포에서 흔히 선택되는 방식으로, 정확도 손실을 허용 가능한 수준으로 유지하면서 모델 크기를 절반으로 줄이고 모바일 GPU의 FP16 연산 유닛을 활용할 수 있게 해줍니다.
onnx2tf -i ocr_det_v5_fixed.onnx -o converted_det \ -b 1 -ois x:1,3,640,640 -n여기서 -ois 파라미터는 입력의 정적 셰이프(Static Shape)를 지정합니다. 정적 셰이프는 GPU 가속에 매우 중요하며, 동적 셰이프는 추론할 때마다 GPU 프로그램을 다시 컴파일해야 하므로 성능에 심각한 영향을 미칩니다.
텍스트 검출: DBNet의 미분 가능한 이진화
PP-OCRv5의 검출 모듈은 DBNet(Differentiable Binarization Network)을 기반으로 합니다4. 전통적인 텍스트 검출 방법은 고정된 임계값을 사용하여 이진화를 수행하지만, DBNet의 혁신은 네트워크가 각 픽셀의 최적 임계값을 스스로 학습하도록 하는 데 있습니다.
flowchart TB subgraph DBNet["DBNet 아키텍처"] direction TB IMG[입력 이미지<br/>H x W x 3] BB[Backbone<br/>MobileNetV3] FPN[FPN 특징 피라미드<br/>다중 스케일 융합]
subgraph Heads["듀얼 브랜치 출력"] PH[확률 맵 브랜치<br/>P: H x W x 1] TH[임계값 맵 브랜치<br/>T: H x W x 1] end
DB["미분 가능한 이진화<br/>B = sigmoid k * P-T"] end
IMG --> BB --> FPN FPN --> PH FPN --> TH PH --> DB TH --> DB표준 이진화 vs 미분 가능한 이진화
표준 이진화는 계단 함수(Step Function)입니다.
이 함수는 미분이 불가능하여 역전파를 통한 엔드 투 엔드 훈련이 불가능합니다. DBNet은 이를 근사하는 함수를 제안했습니다.
여기서 는 확률 맵, 는 임계값 맵(네트워크가 학습), 는 증폭 계수(훈련 시 50으로 설정)입니다.
TIP
이 공식은 본질적으로 Sigmoid 함수이며, 입력이 로 바뀐 것뿐입니다. 가 충분히 크면 계단 함수에 가까워지면서도 미분 가능성을 유지합니다.
후처리 프로세스의 엔지니어링 구현
PPOCRv5-Android 프로젝트에서 후처리 프로세스는 postprocess.cpp에 구현되어 있습니다. 핵심 프로세스는 다음과 같습니다.
flowchart LR subgraph Input["모델 출력"] PM[확률 맵 P<br/>640 x 640] end
subgraph Binary["이진화"] BT[임계값 필터링<br/>threshold=0.1] BM[이진 맵<br/>640 x 640] end
subgraph Contour["윤곽선 검출"] DS[4x 다운샘플링<br/>160 x 160] CC[연결 요소 분석<br/>BFS 순회] BD[경계점 추출] end
subgraph Geometry["기하 연산"] CH[볼록 껍질 계산<br/>Graham Scan] RR[회전 칼리퍼스<br/>최소 외접 사각형] UC[Unclip 확장<br/>ratio=1.5] end
subgraph Output["출력"] TB[RotatedRect<br/>center, size, angle] end
PM --> BT --> BM BM --> DS --> CC --> BD BD --> CH --> RR --> UC --> TB실제 코드에서 TextDetector::Impl::Detect 메서드는 전체 검출 프로세스를 보여줍니다.
std::vector<RotatedRect> Detect(const uint8_t *image_data, int width, int height, int stride, float *detection_time_ms) { // 1. 스케일 비율 계산 scale_x_ = static_cast<float>(width) / kDetInputSize; scale_y_ = static_cast<float>(height) / kDetInputSize;
// 2. 쌍선형 보간법으로 640x640으로 리사이즈 image_utils::ResizeBilinear(image_data, width, height, stride, resized_buffer_.data(), kDetInputSize, kDetInputSize);
// 3. ImageNet 표준화 PrepareFloatInput();
// 4. 추론 auto run_result = compiled_model_->Run(input_buffers_, output_buffers_);
// 5. 이진화 BinarizeOutput(prob_map, total_pixels);
// 6. 윤곽선 검출 auto contours = postprocess::FindContours(binary_map_.data(), kDetInputSize, kDetInputSize);
// 7. 최소 외접 사각형 + Unclip for (const auto &contour : contours) { RotatedRect rect = postprocess::MinAreaRect(contour); UnclipBox(rect, kUnclipRatio); // 좌표를 원본 이미지로 매핑 rect.center_x *= scale_x_; rect.center_y *= scale_y_; // ... }}이 프로세스의 핵심은 ‘최소 외접 회전 사각형’입니다. 축에 정렬된 경계 상자(AABB)와 달리, 회전 사각형은 어떤 각도의 텍스트라도 긴밀하게 감쌀 수 있어 자연 환경에서의 기울어진 텍스트 처리에 매우 중요합니다.
Unclip: 텍스트 박스 팽창 알고리즘
DBNet이 출력하는 텍스트 영역은 실제 텍스트보다 약간 작은 경우가 많습니다. 네트워크가 텍스트의 ‘핵심 영역’을 학습하기 때문입니다. 완전한 텍스트 경계를 얻으려면 검출된 다각형에 대해 팽창(Unclip) 작업을 수행해야 합니다.
Unclip의 수학적 원리는 Vatti 다각형 클리핑 알고리즘의 역연산을 기반으로 합니다. 다각형 와 팽창 거리 가 주어졌을 때, 팽창된 다각형 는 다음을 만족합니다.
여기서 는 다각형의 면적, 은 둘레, 은 팽창 비율(보통 1.5로 설정)입니다.
postprocess.cpp에서 UnclipBox 함수는 이 로직을 구현합니다.
void UnclipBox(RotatedRect &box, float unclip_ratio) { // 팽창 거리 계산 float area = box.width * box.height; float perimeter = 2.0f * (box.width + box.height);
if (perimeter < 1e-6f) return; // 0으로 나누기 방지
// d = A * r / L float distance = area * unclip_ratio / perimeter;
// 바깥쪽으로 팽창: 너비와 높이를 각각 2d씩 증가 box.width += 2.0f * distance; box.height += 2.0f * distance;}이 단순화된 버전은 텍스트 박스가 사각형이라고 가정합니다. 더 복잡한 다각형의 경우 전체 Clipper 라이브러리를 사용하여 다각형 오프셋을 구현해야 합니다.
// 전체 다각형 Unclip (Clipper 라이브러리 사용)ClipperLib::Path polygon;for (const auto& pt : contour) { polygon.push_back(ClipperLib::IntPoint( static_cast<int>(pt.x * 1000), // 정밀도 유지를 위해 확대 static_cast<int>(pt.y * 1000) ));}
ClipperLib::ClipperOffset offset;offset.AddPath(polygon, ClipperLib::jtRound, ClipperLib::etClosedPolygon);
ClipperLib::Paths solution;offset.Execute(solution, distance * 1000); // 팽창NOTE
PPOCRv5-Android는 전체 다각형 오프셋 대신 단순화된 사각형 팽창을 선택했습니다. 그 이유는 다음과 같습니다.
- 대부분의 텍스트 박스는 사각형에 가깝습니다.
- 전체 Clipper 라이브러리는 바이너리 크기를 크게 증가시킵니다.
- 단순화된 버전의 성능이 더 좋습니다.
텍스트 인식: SVTRv2와 CTC 디코딩
검출이 ‘글자가 어디에 있는지 찾는 것’이라면, 인식은 ‘글자가 무엇인지 읽는 것’입니다. PP-OCRv5의 인식 모듈은 SVTRv2(Scene Text Recognition with Visual Transformer v2)를 기반으로 합니다5.
SVTRv2의 아키텍처 혁신
SVTRv2는 이전 세대인 SVTR에 비해 세 가지 핵심적인 개선 사항이 있습니다.
flowchart TB subgraph SVTRv2["SVTRv2 아키텍처"] direction TB
subgraph Encoder["비전 인코더"] PE[Patch Embedding<br/>4x4 컨볼루션]
subgraph Mixing["혼합 어텐션 블록 x12"] LA[Local Attention<br/>7x7 윈도우] GA[Global Attention<br/>전역 수용 영역] FFN[Feed Forward<br/>MLP] end end
subgraph Decoder["CTC 디코더"] FC[완전 연결 계층<br/>D -> 18384] SM[Softmax] CTC[CTC Decode] end end
PE --> LA --> GA --> FFN FFN --> FC --> SM --> CTC-
혼합 어텐션 메커니즘: 로컬 어텐션(획의 세부 사항 포착)과 글로벌 어텐션(문자 구조 이해)을 교대로 사용합니다. 로컬 어텐션은 7x7 슬라이딩 윈도우를 사용하여 계산 복잡도를 에서 로 낮췄습니다.
-
다중 스케일 특징 융합: ViT의 단일 해상도와 달리, SVTRv2는 CNN의 피라미드 구조와 유사하게 깊이에 따라 다른 특징 맵 해상도를 사용합니다.
-
시맨틱 가이드 모듈(Semantic Guidance Module): 인코더 끝에 가벼운 시맨틱 브랜치를 추가하여 모델이 시각적 특징뿐만 아니라 문자의 의미론적 관계를 이해하도록 돕습니다.
이러한 개선을 통해 SVTRv2는 CTC 디코딩의 단순함을 유지하면서도 Attention 기반 방식에 필적하는 정확도를 달성했습니다6.
왜 Attention이 아닌 CTC인가?
텍스트 인식에는 두 가지 주요 패러다임이 있습니다.
- CTC(Connectionist Temporal Classification): 인식을 시퀀스 라벨링 문제로 간주하며, 출력을 입력과 정렬합니다.
- Attention 기반 디코더: 어텐션 메커니즘을 사용하여 문자를 하나씩 생성합니다.
Attention 방식은 보통 정확도가 더 높지만, CTC 방식은 더 단순하고 빠릅니다. SVTRv2의 기여는 비전 인코더를 개선하여 CTC 방식이 Attention 방식의 정확도에 도달하거나 심지어 능가하게 만든 데 있습니다6.
CTC 디코딩의 핵심은 ‘중복 병합’과 ‘공백 제거’입니다.
flowchart LR subgraph Input["모델 출력"] L["Logits<br/>[T, 18384]"] end
subgraph Argmax["Argmax NEON"] A1["t=0: blank"] A2["t=1: H"] A3["t=2: H"] A4["t=3: blank"] A5["t=4: e"] A6["t=5: l"] A7["t=6: l"] A8["t=7: l"] A9["t=8: o"] end
subgraph Merge["중복 병합"] M["blank, H, blank, e, l, o"] end
subgraph Remove["공백 제거"] R["H, e, l, o"] end
subgraph Output["출력"] O["Helo - 오류"] end
L --> A1 & A2 & A3 & A4 & A5 & A6 & A7 & A8 & A9 A1 & A2 & A3 & A4 & A5 & A6 & A7 & A8 & A9 --> Merge --> Remove --> Output잠깐, 여기서 문제가 발생합니다. 원본 텍스트가 “Hello”라면 두 개의 ‘l’이 잘못 병합되었습니다. CTC의 해결책은 중복 문자 사이에 blank 토큰을 삽입하는 것입니다.
올바른 인코딩: [blank, H, e, l, blank, l, o]디코딩 결과: "Hello"NEON 최적화된 CTC 디코딩
PPOCRv5-Android의 CTC 디코딩은 NEON 최적화된 Argmax를 사용합니다. text_recognizer.cpp에서 확인해 보겠습니다.
inline void ArgmaxNeon8(const float *__restrict__ data, int size, int &max_idx, float &max_val) { if (size < 16) { // 스칼라 폴백 max_idx = 0; max_val = data[0]; for (int i = 1; i < size; ++i) { if (data[i] > max_val) { max_val = data[i]; max_idx = i; } } return; }
// NEON 벡터화: 한 번에 4개의 float 처리 float32x4_t v_max = vld1q_f32(data); int32x4_t v_idx = {0, 1, 2, 3}; int32x4_t v_max_idx = v_idx; const int32x4_t v_four = vdupq_n_s32(4);
int i = 4; for (; i + 4 <= size; i += 4) { float32x4_t v_curr = vld1q_f32(data + i); v_idx = vaddq_s32(v_idx, v_four);
// 벡터화된 비교 및 조건부 선택 uint32x4_t cmp = vcgtq_f32(v_curr, v_max); v_max = vbslq_f32(cmp, v_curr, v_max); // 더 큰 값 선택 v_max_idx = vbslq_s32(cmp, v_idx, v_max_idx); // 해당 인덱스 선택 }
// 수평 리덕션: 4개의 후보 중 최댓값 찾기 float max_vals[4]; int32_t max_idxs[4]; vst1q_f32(max_vals, v_max); vst1q_s32(max_idxs, v_max_idx); // ... 최종 비교}18,384개 카테고리에 대한 Argmax의 경우, NEON 최적화를 통해 약 3배의 속도 향상을 얻을 수 있습니다.
CTC 손실 함수와 디코딩의 수학적 원리
CTC의 핵심 아이디어는 입력 시퀀스 와 가능한 모든 정렬 경로 가 주어졌을 때, 타겟 시퀀스 의 확률을 계산하는 것입니다.
여기서 는 경로 를 출력 시퀀스 로 매핑하는 ‘다대일 매핑 함수’입니다(중복 병합 및 공백 제거를 통해).
추론 시에는 전체 Beam Search 대신 그리디 디코딩(Greedy Decoding)을 사용합니다.
std::string CTCGreedyDecode(const float* logits, int time_steps, int num_classes, const std::vector<std::string>& dictionary) { std::string result; int prev_idx = -1; // 중복 병합용
for (int t = 0; t < time_steps; ++t) { // 현재 타임스텝에서 최대 확률 카테고리 찾기 int max_idx = 0; float max_val = logits[t * num_classes];
for (int c = 1; c < num_classes; ++c) { if (logits[t * num_classes + c] > max_val) { max_val = logits[t * num_classes + c]; max_idx = c; } }
// CTC 디코딩 규칙: // 1. blank 토큰(인덱스 0) 건너뛰기 // 2. 연속 중복 문자 병합 if (max_idx != 0 && max_idx != prev_idx) { result += dictionary[max_idx - 1]; // blank가 인덱스 0을 차지하므로 -1 }
prev_idx = max_idx; }
return result;}그리디 디코딩의 시간 복잡도는 입니다. 여기서 는 타임스텝 수, 는 카테고리 수입니다. PP-OCRv5의 경우 , 이므로 매 디코딩마다 약 150만 번의 비교가 필요합니다. 이것이 NEON 최적화가 중요한 이유입니다.
TIP
Beam Search는 디코딩 정확도를 높일 수 있지만, 계산량이 그리디 디코딩의 배(는 beam width)입니다. 모바일 환경에서는 보통 그리디 디코딩이 더 나은 선택입니다.
문자 사전: 18,383개 문자의 도전
PP-OCRv5는 다음을 포함하여 18,383개의 문자를 지원합니다.
- 간체 중국어 상용구
- 번체 중국어 상용구
- 영문 알파벳 및 숫자
- 일본어 히라가나, 가타카나
- 상용 문장 부호 및 특수 문자
이 사전은 keys_v5.txt 파일에 한 줄에 한 문자씩 저장되어 있습니다. CTC 디코딩 시 모델 출력 logits의 형태는 [1, T, 18384]이며, 여기서 18384 = 18383개 문자 + 1개 blank 토큰입니다.
LiteRT C++ API: 2024년 재설계된 현대적 인터페이스
PPOCRv5-Android는 2024년 재설계된 LiteRT의 C++ API를 사용합니다. 이 API 세트는 더욱 현대적인 인터페이스 설계를 제공합니다. 기존 TFLite C API와 비교하여 새로운 API는 더 나은 타입 안정성과 리소스 관리 능력을 갖추고 있습니다.
신구 API 비교
LiteRT 2024 재설계는 현저한 API 변화를 가져왔습니다.
| 특성 | 이전 API (TFLite) | 새로운 API (LiteRT) |
|---|---|---|
| 네임스페이스 | tflite:: | litert:: |
| 오류 처리 | TfLiteStatus 열거형 반환 | Expected<T> 타입 반환 |
| 메모리 관리 | 수동 관리 | RAII 자동 관리 |
| Delegate 설정 | 분산된 API | 통합된 Options 클래스 |
| 텐서 접근 | 포인터 + 수동 타입 변환 | 타입 안전한 TensorBuffer |
새로운 API의 핵심 장점은 타입 안정성과 리소스 자동 관리입니다. 오류 처리를 예로 들어 보겠습니다.
// 이전 API: 매 반환값마다 수동 확인 필요TfLiteStatus status = TfLiteInterpreterAllocateTensors(interpreter);if (status != kTfLiteOk) { // 오류 처리}
// 새로운 API: Expected 타입을 사용하여 체이닝 지원auto model_result = litert::CompiledModel::Create(env, model_path, options);if (!model_result) { LOGE(TAG, "Error: %s", model_result.Error().Message().c_str()); return false;}auto model = std::move(*model_result); // 수명 주기 자동 관리환경 및 모델 초기화
text_detector.cpp에서 초기화 프로세스는 다음과 같습니다.
bool Initialize(const std::string &model_path, AcceleratorType accelerator_type) { // 1. LiteRT 환경 생성 auto env_result = litert::Environment::Create({}); if (!env_result) { LOGE(TAG, "Failed to create LiteRT environment: %s", env_result.Error().Message().c_str()); return false; } env_ = std::move(*env_result);
// 2. 하드웨어 가속기 설정 auto options_result = litert::Options::Create(); auto hw_accelerator = ToLiteRtAccelerator(accelerator_type); options.SetHardwareAccelerators(hw_accelerator);
// 3. 모델 컴파일 auto model_result = litert::CompiledModel::Create(*env_, model_path, options); if (!model_result) { LOGW(TAG, "Failed to create CompiledModel with accelerator %d: %s", static_cast<int>(accelerator_type), model_result.Error().Message().c_str()); return false; } compiled_model_ = std::move(*model_result);
// 4. 입력 텐서 셰이프 조정 std::vector<int> input_dims = {1, kDetInputSize, kDetInputSize, 3}; compiled_model_->ResizeInputTensor(0, absl::MakeConstSpan(input_dims));
// 5. 관리형 Buffer 생성 CreateBuffersWithCApi();
return true;}Managed Tensor Buffer: 제로 카피 추론의 핵심
LiteRT의 Managed Tensor Buffer는 고성능 추론을 구현하는 핵심입니다. GPU Delegate가 CPU-GPU 데이터 전송 없이 Buffer에 직접 접근할 수 있게 해줍니다.
bool CreateBuffersWithCApi() { LiteRtCompiledModel c_model = compiled_model_->Get(); LiteRtEnvironment c_env = env_->Get();
// 입력 Buffer 요구사항 가져오기 LiteRtTensorBufferRequirements input_requirements = nullptr; LiteRtGetCompiledModelInputBufferRequirements( c_model, /*signature_index=*/0, /*input_index=*/0, &input_requirements);
// 텐서 타입 정보 가져오기 auto input_type = compiled_model_->GetInputTensorType(0, 0); LiteRtRankedTensorType tensor_type = static_cast<LiteRtRankedTensorType>(*input_type);
// 관리형 Buffer 생성 LiteRtTensorBuffer input_buffer = nullptr; LiteRtCreateManagedTensorBufferFromRequirements( c_env, &tensor_type, input_requirements, &input_buffer);
// C++ 객체로 래핑하여 수명 주기 자동 관리 input_buffers_.push_back( litert::TensorBuffer::WrapCObject(input_buffer, litert::OwnHandle::kYes)); return true;}이 설계의 장점은 다음과 같습니다.
- 제로 카피(Zero-copy) 추론: GPU Delegate가 직접 Buffer에 접근하여 CPU-GPU 데이터 전송이 불필요합니다.
- 자동 메모리 관리:
OwnHandle::kYes를 통해 C++ 객체 소멸 시 Buffer가 자동으로 해제됩니다. - 타입 안정성: 컴파일 타임에 텐서 타입 일치 여부를 확인합니다.
GPU 가속: OpenCL의 선택과 트레이드오프
LiteRT는 다양한 하드웨어 가속 옵션을 제공합니다.
flowchart TB subgraph Delegates["LiteRT Delegate 생태계"] direction TB GPU_CL[GPU Delegate<br/>OpenCL Backend] GPU_GL[GPU Delegate<br/>OpenGL ES Backend] NNAPI[NNAPI Delegate<br/>Android HAL] XNN[XNNPACK Delegate<br/>CPU Optimized] end
subgraph Hardware["하드웨어 매핑"] direction TB ADRENO[Adreno GPU<br/>Qualcomm] MALI[Mali GPU<br/>ARM] NPU[NPU/DSP<br/>제조사 특정] CPU[ARM CPU<br/>NEON] end
GPU_CL --> ADRENO GPU_CL --> MALI GPU_GL --> ADRENO GPU_GL --> MALI NNAPI --> NPU XNN --> CPU| 가속기 | 백엔드 | 장점 | 단점 |
|---|---|---|---|
| GPU | OpenCL | 폭넓은 지원, 우수한 성능 | Android 표준 컴포넌트 아님 |
| GPU | OpenGL ES | Android 표준 컴포넌트 | OpenCL보다 성능 낮음 |
| NPU | NNAPI | 최고 성능 | 기기 호환성 부족 |
| CPU | XNNPACK | 가장 넓은 호환성 | 성능 가장 낮음 |
PPOCRv5-Android는 OpenCL을 주요 가속 백엔드로 선택했습니다. Google은 2020년에 TFLite용 OpenCL 백엔드를 발표했으며, OpenGL ES 백엔드에 비해 Adreno GPU에서 약 2배의 속도 향상을 달성했습니다7.
OpenCL의 우위는 몇 가지 측면에서 비롯됩니다.
- 설계 목적: OpenCL은 처음부터 범용 계산을 위해 설계된 반면, OpenGL은 그래픽 렌더링 API이며 나중에 계산 셰이더 지원이 추가되었습니다.
- 상수 메모리: OpenCL의 상수 메모리는 신경망 가중치 접근에 매우 효율적입니다.
- FP16 지원: OpenCL은 기본적으로 반정밀도 부동 소수점을 지원하지만, OpenGL은 지원이 늦었습니다.
하지만 OpenCL에는 치명적인 결함이 있습니다. Android의 표준 컴포넌트가 아니라는 점입니다. 제조사마다 OpenCL 구현 품질이 천차만별이며, 일부 기기에서는 전혀 지원되지 않기도 합니다.
OpenCL vs OpenGL ES: 성능 심층 비교
OpenCL의 장점을 이해하려면 GPU 아키텍처 수준까지 내려가야 합니다. Qualcomm Adreno 640을 예로 들어 보겠습니다.
flowchart TB subgraph Adreno["Adreno 640 아키텍처"] direction TB
subgraph SP["Shader Processors x2"] ALU1[ALU Array<br/>256 FP32 / 512 FP16] ALU2[ALU Array<br/>256 FP32 / 512 FP16] end
subgraph Memory["메모리 계층"] L1[L1 Cache<br/>SP당 16KB] L2[L2 Cache<br/>1MB 공유] GMEM[Global Memory<br/>LPDDR4X] end
subgraph Special["전용 유닛"] TMU[Texture Unit<br/>쌍선형 보간] CONST[Constant Cache<br/>가중치 가속] end end
ALU1 --> L1 ALU2 --> L1 L1 --> L2 --> GMEM TMU --> L1 CONST --> ALU1 & ALU2OpenCL의 성능 우위는 다음에서 기인합니다.
| 특성 | OpenCL | OpenGL ES Compute |
|---|---|---|
| 상수 메모리 | 기본 지원, 하드웨어 가속 | 시뮬레이션 필요 (UBO 사용) |
| 워크그룹 크기 | 유연한 설정 | 셰이더 모델에 의해 제한됨 |
| 메모리 배리어 | 세밀한 제어 | 거친 제어 |
| FP16 계산 | cl_khr_fp16 확장 | mediump 정밀도 필요 |
| 디버깅 도구 | Snapdragon Profiler | 제한적인 지원 |
컨볼루션 연산에서 가중치는 보통 상수입니다. OpenCL은 가중치를 상수 메모리에 넣어 하드웨어 수준의 브로드캐스트 최적화를 누릴 수 있습니다. 반면 OpenGL ES는 가중치를 Uniform Buffer Object(UBO)로 전달해야 하므로 메모리 접근 오버헤드가 증가합니다.
NOTE
Google은 Android 7.0 이후 앱이 OpenCL 라이브러리를 직접 로드하는 것을 제한했습니다. 하지만 LiteRT의 GPU Delegate는 dlopen을 통해 시스템의 OpenCL 구현을 동적으로 로드하여 이 제한을 우회합니다. 이것이 GPU Delegate가 런타임에 OpenCL 가용성을 감지해야 하는 이유입니다.
점진적 기능 저하(Graceful Degradation) 전략
PPOCRv5-Android는 다음과 같이 점진적 기능 저하 전략을 구현했습니다.
constexpr AcceleratorType kFallbackChain[] = { AcceleratorType::kGpu, // GPU 우선 AcceleratorType::kCpu, // CPU로 폴백};
std::unique_ptr<OcrEngine> OcrEngine::Create( const std::string &det_model_path, const std::string &rec_model_path, const std::string &keys_path, AcceleratorType accelerator_type) {
auto engine = std::unique_ptr<OcrEngine>(new OcrEngine()); int start_index = GetFallbackStartIndex(accelerator_type);
for (int i = start_index; i < kFallbackChainSize; ++i) { AcceleratorType current = kFallbackChain[i];
auto detector = TextDetector::Create(det_model_path, current); if (!detector) continue;
auto recognizer = TextRecognizer::Create(rec_model_path, keys_path, current); if (!recognizer) continue;
engine->detector_ = std::move(detector); engine->recognizer_ = std::move(recognizer); engine->active_accelerator_ = current;
engine->WarmUp(); return engine; } return nullptr;}이 전략은 어떤 기기에서든 앱이 실행될 수 있도록 보장하며, 성능만 기기 사양에 맞춰 조정됩니다.
네이티브 계층: C++와 NEON 최적화
왜 Kotlin이 아닌 C++를 사용할까요?
답은 간단합니다. 성능 때문입니다. 이미지 전처리는 수많은 픽셀 단위 연산을 포함하며, 이러한 연산을 JVM에서 처리하는 것은 오버헤드가 너무 큽니다. 더 중요한 것은 C++를 사용하면 ARM NEON SIMD 지침을 직접 사용하여 벡터화 연산을 구현할 수 있다는 점입니다.
NEON: ARM의 SIMD 지침 세트
NEON은 ARM 프로세서의 SIMD(Single Instruction, Multiple Data) 확장입니다. 하나의 지침으로 여러 데이터 요소를 동시에 처리할 수 있게 해줍니다.
flowchart LR subgraph NEON["128비트 NEON 레지스터"] direction TB F4["4x float32"] I8["8x int16"] B16["16x int8"] end
subgraph Operations["벡터화 연산"] direction TB LD["vld1q_f32<br/>4개 float 로드"] SUB["vsubq_f32<br/>4로 병렬 뺄셈"] MUL["vmulq_f32<br/>4로 병렬 곱셈"] ST["vst1q_f32<br/>4개 float 저장"] end
subgraph Speedup["성능 향상"] S1["스칼라: 4개 지침"] S2["NEON: 1개 지침"] S3["이론적 가속: 4배"] end
F4 --> LD LD --> SUB --> MUL --> ST ST --> S3PPOCRv5-Android는 여러 핵심 경로에서 NEON 최적화를 사용합니다. 이진화를 예로 들어 보겠습니다(text_detector.cpp).
void BinarizeOutput(const float *prob_map, int total_pixels) {#if defined(__ARM_NEON) || defined(__ARM_NEON__) const float32x4_t v_threshold = vdupq_n_f32(kBinaryThreshold); const uint8x16_t v_255 = vdupq_n_u8(255); const uint8x16_t v_0 = vdupq_n_u8(0);
int i = 0; for (; i + 16 <= total_pixels; i += 16) { // 한 번에 16개 픽셀 처리 float32x4_t f0 = vld1q_f32(prob_map + i); float32x4_t f1 = vld1q_f32(prob_map + i + 4); float32x4_t f2 = vld1q_f32(prob_map + i + 8); float32x4_t f3 = vld1q_f32(prob_map + i + 12);
// 벡터화된 비교 uint32x4_t cmp0 = vcgtq_f32(f0, v_threshold); uint32x4_t cmp1 = vcgtq_f32(f1, v_threshold); uint32x4_t cmp2 = vcgtq_f32(f2, v_threshold); uint32x4_t cmp3 = vcgtq_f32(f3, v_threshold);
// uint8로 내로잉(Narrowing) uint16x4_t n0 = vmovn_u32(cmp0); uint16x4_t n1 = vmovn_u32(cmp1); uint16x8_t n01 = vcombine_u16(n0, n1); // ... 병합 및 저장 } // 남은 픽셀은 스칼라 폴백으로 처리 for (; i < total_pixels; ++i) { binary_map_[i] = (prob_map[i] > kBinaryThreshold) ? 255 : 0; }#else // 순수 스칼라 구현 for (int i = 0; i < total_pixels; ++i) { binary_map_[i] = (prob_map[i] > kBinaryThreshold) ? 255 : 0; }#endif}이 코드의 핵심 최적화 포인트는 다음과 같습니다.
- 배치 로드:
vld1q_f32로 한 번에 4개의 float을 로드하여 메모리 접근 횟수를 줄입니다. - 벡터화 비교:
vcgtq_f32로 4개의 값을 동시에 비교하여 마스크를 생성합니다. - 타입 내로잉:
vmovn_u32로 32비트 결과를 16비트로 압축하고, 최종적으로 8비트까지 압축합니다.
스칼라 구현에 비해 NEON 최적화는 3~4배의 속도 향상을 가져올 수 있습니다8.
ImageNet 정규화의 NEON 구현
이미지 정규화는 전처리의 핵심 단계입니다. ImageNet 표준화는 다음 공식을 사용합니다.
여기서 , 입니다(RGB 채널).
image_utils.cpp에서 NEON 최적화된 정규화 구현은 다음과 같습니다.
void NormalizeImageNet(const uint8_t* src, int width, int height, int stride, float* dst) { // ImageNet 정규화 파라미터 constexpr float kMeanR = 0.485f, kMeanG = 0.456f, kMeanB = 0.406f; constexpr float kStdR = 0.229f, kStdG = 0.224f, kStdB = 0.225f; constexpr float kInvStdR = 1.0f / kStdR; constexpr float kInvStdG = 1.0f / kStdG; constexpr float kInvStdB = 1.0f / kStdB; constexpr float kScale = 1.0f / 255.0f;
#if defined(__ARM_NEON) || defined(__ARM_NEON__) // 사전 계산: (1/255) / std = 1 / (255 * std) const float32x4_t v_scale_r = vdupq_n_f32(kScale * kInvStdR); const float32x4_t v_scale_g = vdupq_n_f32(kScale * kInvStdG); const float32x4_t v_scale_b = vdupq_n_f32(kScale * kInvStdB);
// 사전 계산: -mean / std const float32x4_t v_bias_r = vdupq_n_f32(-kMeanR * kInvStdR); const float32x4_t v_bias_g = vdupq_n_f32(-kMeanG * kInvStdG); const float32x4_t v_bias_b = vdupq_n_f32(-kMeanB * kInvStdB);
for (int y = 0; y < height; ++y) { const uint8_t* row = src + y * stride; float* dst_row = dst + y * width * 3;
int x = 0; for (; x + 4 <= width; x += 4) { // 4개의 RGBA 픽셀 로드 (16바이트) uint8x16_t rgba = vld1q_u8(row + x * 4);
// 디인터리빙(De-interleaving): RGBARGBARGBARGBA -> RRRR, GGGG, BBBB, AAAA uint8x16x4_t channels = vld4q_u8(row + x * 4);
// uint8 -> uint16 -> uint32 -> float32 uint16x8_t r16 = vmovl_u8(vget_low_u8(channels.val[0])); uint16x8_t g16 = vmovl_u8(vget_low_u8(channels.val[1])); uint16x8_t b16 = vmovl_u8(vget_low_u8(channels.val[2]));
float32x4_t r_f = vcvtq_f32_u32(vmovl_u16(vget_low_u16(r16))); float32x4_t g_f = vcvtq_f32_u32(vmovl_u16(vget_low_u16(g16))); float32x4_t b_f = vcvtq_f32_u32(vmovl_u16(vget_low_u16(b16)));
// 정규화: (x / 255 - mean) / std = x * (1/255/std) + (-mean/std) r_f = vmlaq_f32(v_bias_r, r_f, v_scale_r); // fused multiply-add g_f = vmlaq_f32(v_bias_g, g_f, v_scale_g); b_f = vmlaq_f32(v_bias_b, b_f, v_scale_b);
// 인터리빙 저장: RRRR, GGGG, BBBB -> RGBRGBRGBRGB float32x4x3_t rgb = {r_f, g_f, b_f}; vst3q_f32(dst_row + x * 3, rgb); }
// 남은 픽셀 스칼라 처리 for (; x < width; ++x) { const uint8_t* px = row + x * 4; float* dst_px = dst_row + x * 3; dst_px[0] = (px[0] * kScale - kMeanR) * kInvStdR; dst_px[1] = (px[1] * kScale - kMeanG) * kInvStdG; dst_px[2] = (px[2] * kScale - kMeanB) * kInvStdB; } }#else // 스칼라 구현 (생략)#endif}이 코드의 핵심 최적화 기법은 다음과 같습니다.
- 상수 사전 계산:
(x - mean) / std를x * scale + bias로 변환하여 런타임 나눗셈을 줄입니다. - Fused Multiply-Add:
vmlaq_f32를 사용하여 단일 지침으로 곱셈과 덧셈을 완료합니다. - 디인터리빙 로드:
vld4q_u8이 자동으로 RGBA를 4개의 채널로 분리합니다. - 인터리빙 저장:
vst3q_f32가 RGB 3개 채널을 인터리빙하여 메모리에 씁니다.
제로 OpenCV 의존성
많은 OCR 프로젝트가 이미지 전처리를 위해 OpenCV에 의존합니다. OpenCV는 강력하지만 패키지 크기가 매우 큽니다. Android용 OpenCV 라이브러리는 보통 10MB를 초과합니다.
PPOCRv5-Android는 ‘제로 OpenCV 의존성’ 노선을 선택했습니다. 모든 이미지 전처리 작업은 image_utils.cpp에서 순수 C++로 구현되었습니다.
- 쌍선형 보간 리사이즈: NEON 최적화를 지원하는 수동 구현
- 정규화: ImageNet 표준화 및 인식 표준화
- 투영 변환: 원본 이미지에서 임의 각도의 텍스트 영역 크롭
쌍선형 보간법의 NEON 구현
쌍선형 보간법(Bilinear Interpolation)은 이미지 스케일링의 핵심 알고리즘입니다. 소스 이미지 좌표 가 주어졌을 때, 타겟 픽셀 값을 다음과 같이 계산합니다.
여기서 , 이며, 는 4개 인접 픽셀의 값입니다.
void ResizeBilinear(const uint8_t* src, int src_w, int src_h, int src_stride, uint8_t* dst, int dst_w, int dst_h) { const float scale_x = static_cast<float>(src_w) / dst_w; const float scale_y = static_cast<float>(src_h) / dst_h;
for (int dy = 0; dy < dst_h; ++dy) { const float sy = (dy + 0.5f) * scale_y - 0.5f; const int y0 = std::max(0, static_cast<int>(std::floor(sy))); const int y1 = std::min(src_h - 1, y0 + 1); const float beta = sy - y0; const float inv_beta = 1.0f - beta;
const uint8_t* row0 = src + y0 * src_stride; const uint8_t* row1 = src + y1 * src_stride; uint8_t* dst_row = dst + dy * dst_w * 4;
#if defined(__ARM_NEON) || defined(__ARM_NEON__) // NEON: 한 번에 4개의 타겟 픽셀 처리 const float32x4_t v_beta = vdupq_n_f32(beta); const float32x4_t v_inv_beta = vdupq_n_f32(inv_beta);
int dx = 0; for (; dx + 4 <= dst_w; dx += 4) { // 4개의 소스 좌표 계산 float sx[4]; for (int i = 0; i < 4; ++i) { sx[i] = ((dx + i) + 0.5f) * scale_x - 0.5f; }
// alpha 가중치 로드 float alpha[4], inv_alpha[4]; int x0[4], x1[4]; for (int i = 0; i < 4; ++i) { x0[i] = std::max(0, static_cast<int>(std::floor(sx[i]))); x1[i] = std::min(src_w - 1, x0[i] + 1); alpha[i] = sx[i] - x0[i]; inv_alpha[i] = 1.0f - alpha[i]; }
// 각 채널에 대해 쌍선형 보간 수행 for (int c = 0; c < 4; ++c) { // RGBA float32x4_t f00, f10, f01, f11;
// 4개 픽셀의 인접 값 수집 f00 = vsetq_lane_f32(row0[x0[0] * 4 + c], f00, 0); f00 = vsetq_lane_f32(row0[x0[1] * 4 + c], f00, 1); f00 = vsetq_lane_f32(row0[x0[2] * 4 + c], f00, 2); f00 = vsetq_lane_f32(row0[x0[3] * 4 + c], f00, 3); // ... f10, f01, f11 유사하게 처리
// 쌍선형 보간 공식 float32x4_t v_alpha = vld1q_f32(alpha); float32x4_t v_inv_alpha = vld1q_f32(inv_alpha);
float32x4_t top = vmlaq_f32( vmulq_f32(f00, v_inv_alpha), f10, v_alpha ); float32x4_t bottom = vmlaq_f32( vmulq_f32(f01, v_inv_alpha), f11, v_alpha ); float32x4_t result = vmlaq_f32( vmulq_f32(top, v_inv_beta), bottom, v_beta );
// uint8로 변환 후 저장 uint32x4_t result_u32 = vcvtq_u32_f32(result); // ... 저장 } }#endif // 남은 픽셀 스칼라 처리 (생략) }}TIP
쌍선형 보간의 NEON 최적화는 4개 인접 픽셀의 주소가 불연속적이기 때문에 다소 복잡합니다. 더 효율적인 방법은 분리형(Separable) 쌍선형 보간을 사용하는 것입니다. 먼저 수평 방향으로 보간한 다음 수직 방향으로 보간하면 캐시 지역성을 더 잘 활용할 수 있습니다.
이러한 선택은 더 많은 개발 공수가 들지만, 다음과 같은 확실한 이점이 있습니다.
- APK 크기 약 10MB 감소
- 전처리 로직을 완벽하게 제어하여 최적화 용이
- OpenCV 버전 호환성 문제 방지
투영 변환: 회전 사각형에서 표준 텍스트 라인으로
텍스트 인식 모델은 수평 텍스트 라인 이미지를 입력으로 기대합니다. 하지만 검출된 텍스트 박스는 임의 각도의 회전 사각형일 수 있습니다. 투영 변환은 회전 사각형 영역을 ‘똑바로 펴는’ 역할을 합니다.
text_recognizer.cpp에서 CropAndRotate 메서드는 이 기능을 구현합니다.
void CropAndRotate(const uint8_t *__restrict__ image_data, int width, int height, int stride, const RotatedRect &box, int &target_width) { // 회전 사각형의 4개 꼭짓점 계산 const float cos_angle = std::cos(box.angle * M_PI / 180.0f); const float sin_angle = std::sin(box.angle * M_PI / 180.0f); const float half_w = box.width / 2.0f; const float half_h = box.height / 2.0f;
float corners[8]; // 4개 꼭짓점의 (x, y) 좌표 corners[0] = box.center_x + (-half_w * cos_angle - (-half_h) * sin_angle); corners[1] = box.center_y + (-half_w * sin_angle + (-half_h) * cos_angle); // ... 다른 꼭짓점 계산
// 가변 타겟 너비: 가로세로 비율 유지 const float aspect_ratio = src_width / std::max(src_height, 1.0f); target_width = static_cast<int>(kRecInputHeight * aspect_ratio); target_width = std::clamp(target_width, 1, kRecInputWidth); // 48x[1, 320]
// 아핀 변환 행렬 const float a00 = (x1 - x0) * inv_dst_w; const float a01 = (x3 - x0) * inv_dst_h; const float a10 = (y1 - y0) * inv_dst_w; const float a11 = (y3 - y0) * inv_dst_h;
// 쌍선형 보간 샘플링 + 정규화 (NEON 최적화) for (int dy = 0; dy < kRecInputHeight; ++dy) { for (int dx = 0; dx < target_width; ++dx) { float sx = base_sx + a00 * dx; float sy = base_sy + a10 * dx; BilinearSampleNeon(image_data, stride, sx, sy, dst_row + dx * 3); } }}이 구현의 핵심 최적화:
- 가변 너비: 텍스트 박스의 가로세로 비율에 따라 출력 너비를 동적으로 조정하여 과도한 늘림이나 압축을 방지합니다.
- 아핀 변환 근사: 평행사변형에 가까운 텍스트 박스의 경우 투영 변환 대신 아핀 변환을 사용하여 계산량을 줄입니다.
- NEON 쌍선형 보간: 샘플링과 정규화를 한 번의 패스에서 완료하여 메모리 접근을 줄입니다.
JNI: Kotlin과 C++의 가교
JNI(Java Native Interface)는 Kotlin/Java와 C++ 간의 통신 가교 역할을 합니다. 하지만 JNI 호출에는 오버헤드가 따르며, 빈번한 언어 간 호출은 성능에 심각한 영향을 줄 수 있습니다.
PPOCRv5-Android의 설계 원칙은 JNI 호출 횟수를 최소화하는 것입니다. 전체 OCR 프로세스는 단 한 번의 JNI 호출만 필요합니다.
sequenceDiagram participant K as Kotlin Layer participant J as JNI Bridge participant N as Native Layer participant G as GPU
K->>J: process(bitmap) J->>N: RGBA 포인터 전달
Note over N,G: Native 계층에서 모든 작업 수행
N->>N: 이미지 전처리 NEON N->>G: 텍스트 검출 추론 G-->>N: 확률 맵 N->>N: 후처리 윤곽선 검출
loop 각 텍스트 박스 N->>N: 투영 변환 크롭 N->>G: 텍스트 인식 추론 G-->>N: logits N->>N: CTC 디코딩 end
N-->>J: OCR 결과 J-->>K: List OcrResultppocrv5_jni.cpp에서 핵심적인 nativeProcess 함수는 이러한 설계를 보여줍니다.
JNIEXPORT jobjectArray JNICALLJava_me_fleey_ppocrv5_ocr_OcrEngine_nativeProcess( JNIEnv *env, jobject thiz, jlong handle, jobject bitmap) {
auto *engine = reinterpret_cast<ppocrv5::OcrEngine *>(handle);
// Bitmap 픽셀 잠금 void *pixels = nullptr; AndroidBitmap_lockPixels(env, bitmap, &pixels);
// 단 한 번의 JNI 호출로 모든 OCR 작업 완료 auto results = engine->Process( static_cast<const uint8_t *>(pixels), static_cast<int>(bitmap_info.width), static_cast<int>(bitmap_info.height), static_cast<int>(bitmap_info.stride));
AndroidBitmap_unlockPixels(env, bitmap);
// Java 객체 배열을 생성하여 반환 // ...}이 설계는 검출과 인식 사이에서 데이터를 주고받는 오버헤드를 방지합니다.
아키텍처 설계: 모듈화와 테스트 가능성
PPOCRv5-Android의 아키텍처는 ‘관심사 분리(Separation of Concerns)’ 원칙을 따릅니다.
flowchart TB subgraph UI["Jetpack Compose UI Layer"] direction LR CP[CameraPreview] GP[GalleryPicker] RO[ResultOverlay] end
subgraph VM["ViewModel Layer"] OVM[OCRViewModel<br/>상태 관리] end
subgraph Native["Native Layer - C++"] OE[OcrEngine<br/>오케스트레이션]
subgraph Detection["텍스트 검출"] TD[TextDetector] DB[DBNet FP16] end
subgraph Recognition["텍스트 인식"] TR[TextRecognizer] SVTR[SVTRv2 + CTC] end
subgraph Preprocessing["이미지 처리"] IP[ImagePreprocessor<br/>NEON 최적화] PP[PostProcessor<br/>윤곽선 검출] end
subgraph Runtime["LiteRT Runtime"] GPU[GPU Delegate<br/>OpenCL] CPU[CPU Fallback<br/>XNNPACK] end end
CP --> OVM GP --> OVM OVM --> RO OVM <-->|JNI| OE OE --> TD OE --> TR TD --> DB TR --> SVTR TD --> IP TR --> IP DB --> PP DB --> GPU SVTR --> GPU GPU -.->|Fallback| CPU이러한 계층형 아키텍처의 장점은 다음과 같습니다.
- UI 계층: 순수 Kotlin/Compose로 사용자 상호작용에 집중합니다.
- ViewModel 계층: 상태와 비즈니스 로직을 관리합니다.
- Native 계층: 고성능 계산을 담당하며 UI와 완전히 분리(Decoupling)됩니다.
각 계층은 독립적으로 테스트할 수 있습니다. Native 계층은 Google Test로 단위 테스트를 수행하고, ViewModel 계층은 JUnit + MockK로 테스트할 수 있습니다.
Kotlin 계층의 캡슐화
OcrEngine.kt에서 Kotlin 계층은 간결한 API를 제공합니다.
class OcrEngine private constructor( private var nativeHandle: Long,) : Closeable {
companion object { init { System.loadLibrary("ppocrv5_jni") }
fun create( context: Context, acceleratorType: AcceleratorType = AcceleratorType.GPU, ): Result<OcrEngine> = runCatching { initializeCache(context)
val detModelPath = copyAssetToCache(context, "$MODELS_DIR/$DET_MODEL_FILE") val recModelPath = copyAssetToCache(context, "$MODELS_DIR/$REC_MODEL_FILE") val keysPath = copyAssetToCache(context, "$MODELS_DIR/$KEYS_FILE")
val handle = OcrEngine(0).nativeCreate( detModelPath, recModelPath, keysPath, acceleratorType.value, )
if (handle == 0L) { throw OcrException("Failed to create native OCR engine") }
OcrEngine(handle) } }
fun process(bitmap: Bitmap): List<OcrResult> { check(nativeHandle != 0L) { "OcrEngine has been closed" } return nativeProcess(nativeHandle, bitmap)?.toList() ?: emptyList() }
override fun close() { if (nativeHandle != 0L) { nativeDestroy(nativeHandle) nativeHandle = 0 } }}이 설계의 장점:
Result타입을 사용하여 초기화 오류를 처리합니다.Closeable인터페이스를 구현하여use블록에서 리소스를 자동으로 해제합니다.- 모델 파일을 assets에서 캐시 디렉토리로 자동 복사합니다.
콜드 스타트 최적화
첫 번째 추론(콜드 스타트)은 보통 이후 추론(웜 스타트)보다 훨씬 느립니다. 그 이유는 다음과 같습니다.
- GPU Delegate가 OpenCL 프로그램을 컴파일해야 합니다.
- 모델 가중치를 CPU 메모리에서 GPU 메모리로 전송해야 합니다.
- 각종 캐시 예열이 필요합니다.
PPOCRv5-Android는 Warm-up 메커니즘을 통해 콜드 스타트 문제를 완화합니다.
void OcrEngine::WarmUp() { LOGD(TAG, "Starting warm-up (%d iterations)...", kWarmupIterations);
// 작은 테스트 이미지 생성 std::vector<uint8_t> dummy_image(kWarmupImageSize * kWarmupImageSize * 4, 128); for (int i = 0; i < kWarmupImageSize * kWarmupImageSize; ++i) { dummy_image[i * 4 + 0] = static_cast<uint8_t>((i * 7) % 256); dummy_image[i * 4 + 1] = static_cast<uint8_t>((i * 11) % 256); dummy_image[i * 4 + 2] = static_cast<uint8_t>((i * 13) % 256); dummy_image[i * 4 + 3] = 255; }
// 예열을 위해 몇 번의 추론 실행 for (int iter = 0; iter < kWarmupIterations; ++iter) { float detection_time_ms = 0.0f; detector_->Detect(dummy_image.data(), kWarmupImageSize, kWarmupImageSize, kWarmupImageSize * 4, &detection_time_ms); }
LOGD(TAG, "Warm-up completed (accelerator: %s)", AcceleratorName(active_accelerator_));}메모리 정렬 최적화
TextDetector::Impl에서 모든 사전 할당된 버퍼는 64바이트 정렬을 사용합니다.
// 캐시 라인 정렬을 사용한 사전 할당 버퍼alignas(64) std::vector<uint8_t> resized_buffer_;alignas(64) std::vector<float> normalized_buffer_;alignas(64) std::vector<uint8_t> binary_map_;alignas(64) std::vector<float> prob_map_;64바이트 정렬은 현대 ARM 프로세서의 캐시 라인 크기입니다. 정렬된 메모리 접근은 캐시 라인 분할을 방지하여 메모리 접근 효율을 높입니다.
메모리 풀과 객체 재사용
빈번한 메모리 할당과 해제는 성능의 적입니다. PPOCRv5-Android는 사전 할당 전략을 사용하여 초기화 시 필요한 모든 메모리를 한 번에 할당합니다.
class TextDetector::Impl { // 사전 할당된 버퍼, 수명 주기는 Impl과 동일 alignas(64) std::vector<uint8_t> resized_buffer_; // 640 * 640 * 4 = 1.6MB alignas(64) std::vector<float> normalized_buffer_; // 640 * 640 * 3 * 4 = 4.9MB alignas(64) std::vector<uint8_t> binary_map_; // 640 * 640 = 0.4MB alignas(64) std::vector<float> prob_map_; // 640 * 640 * 4 = 1.6MB
bool Initialize(...) { // 런타임 malloc을 피하기 위해 한 번에 할당 resized_buffer_.resize(kDetInputSize * kDetInputSize * 4); normalized_buffer_.resize(kDetInputSize * kDetInputSize * 3); binary_map_.resize(kDetInputSize * kDetInputSize); prob_map_.resize(kDetInputSize * kDetInputSize); return true; }};이 설계의 장점:
- 메모리 파편화 방지: 모든 큰 메모리 블록이 시작 시 할당되어 런타임에 파편화가 발생하지 않습니다.
- 시스템 호출 감소:
malloc은 시스템 호출을 유발할 수 있는데, 사전 할당으로 이를 방지합니다. - 캐시 친화적: 연속적으로 할당된 메모리는 물리적으로도 연속될 가능성이 높아 캐시 히트율을 높입니다.
분기 예측 최적화
현대 CPU는 파이프라인 효율을 높이기 위해 분기 예측을 사용합니다. 잘못된 분기 예측은 파이프라인 플러시를 유발하여 10~20 클록 사이클의 손실을 가져옵니다.
핫 패스(Hot path)에서는 __builtin_expect를 사용하여 컴파일러에게 힌트를 줍니다.
// 대부분의 픽셀은 임계값을 넘지 않음if (__builtin_expect(prob_map[i] > kBinaryThreshold, 0)) { binary_map_[i] = 255;} else { binary_map_[i] = 0;}__builtin_expect(expr, val)은 컴파일러에게 expr의 값이 val일 가능성이 매우 높다고 알려줍니다. 컴파일러는 이를 바탕으로 코드 레이아웃을 조정하여 ‘가능성이 낮은’ 분기를 메인 경로에서 멀리 배치합니다.
루프 언롤링과 소프트웨어 파이프라이닝
계산 집약적인 루프의 경우, 수동 언롤링(Unrolling)을 통해 루프 오버헤드를 줄이고 더 많은 지침 수준의 병렬성을 확보할 수 있습니다.
// 언롤링되지 않은 버전for (int i = 0; i < n; ++i) { dst[i] = src[i] * scale + bias;}
// 4x 언롤링 버전int i = 0;for (; i + 4 <= n; i += 4) { dst[i + 0] = src[i + 0] * scale + bias; dst[i + 1] = src[i + 1] * scale + bias; dst[i + 2] = src[i + 2] * scale + bias; dst[i + 3] = src[i + 3] * scale + bias;}for (; i < n; ++i) { dst[i] = src[i] * scale + bias;}언롤링 후 CPU는 여러 개의 독립적인 곱셈-덧셈 지침을 동시에 실행하여 슈퍼스칼라 아키텍처의 여러 실행 유닛을 충분히 활용할 수 있습니다.
Prefetch 최적화
투영 변환의 내부 루프에서 __builtin_prefetch를 사용하여 다음 행의 데이터를 미리 로드합니다.
for (int dy = 0; dy < kRecInputHeight; ++dy) { // 다음 행 데이터 프리페치 if (dy + 1 < kRecInputHeight) { const float next_sy = y0 + a11 * (dy + 1); const int next_y = static_cast<int>(next_sy); if (next_y >= 0 && next_y < height) { __builtin_prefetch(image_data + next_y * stride, 0, 1); } } // ... 현재 행 처리}이러한 최적화는 메모리 지연을 숨길 수 있어, 현재 행을 처리하는 동안 다음 행의 데이터가 이미 L1 캐시에 들어와 있게 합니다.
후처리의 엔지니어링 디테일
연결 요소 분석과 윤곽선 검출
postprocess.cpp에서 FindContours 함수는 효율적인 연결 요소 분석을 구현합니다.
std::vector<std::vector<Point>> FindContours(const uint8_t *binary_map, int width, int height) { // 1. 계산량을 줄이기 위해 4x 다운샘플링 int ds_width = (width + kDownsampleFactor - 1) / kDownsampleFactor; int ds_height = (height + kDownsampleFactor - 1) / kDownsampleFactor;
std::vector<uint8_t> ds_map(ds_width * ds_height); downsample_binary_map(binary_map, width, height, ds_map.data(), ds_width, ds_height, kDownsampleFactor);
// 2. BFS로 연결 요소 순회 std::vector<int> labels(ds_width * ds_height, 0); int current_label = 0;
for (int y = 0; y < ds_height; ++y) { for (int x = 0; x < ds_width; ++x) { if (pixel_at(ds_map.data(), x, y, ds_width) > 0 && labels[y * ds_width + x] == 0) { current_label++; std::vector<Point> boundary; std::queue<std::pair<int, int>> queue; queue.push({x, y});
while (!queue.empty()) { auto [cx, cy] = queue.front(); queue.pop();
// 경계 픽셀 감지 if (is_boundary_pixel(ds_map.data(), cx, cy, ds_width, ds_height)) { boundary.push_back({ static_cast<float>(cx * kDownsampleFactor + kDownsampleFactor / 2), static_cast<float>(cy * kDownsampleFactor + kDownsampleFactor / 2) }); }
// 4-이웃 확장 for (int d = 0; d < 4; ++d) { int nx = cx + kNeighborDx4[d]; int ny = cy + kNeighborDy4[d]; // ... } }
if (boundary.size() >= 4) { contours.push_back(std::move(boundary)); } } } } return contours;}핵심 최적화 포인트:
- 4x 다운샘플링: 640x640 이진 맵을 160x160으로 다운샘플링하여 계산량을 16배 줄입니다.
- 경계 감지: 전체 연결 요소가 아닌 경계 픽셀만 유지합니다.
- 최대 윤곽선 수 제한:
kMaxContours = 100으로 설정하여 극단적인 상황에서의 성능 문제를 방지합니다.
볼록 껍질과 회전 칼리퍼스 알고리즘
최소 외접 회전 사각형 계산은 두 단계로 나뉩니다. 먼저 볼록 껍질(Convex Hull)을 계산한 다음, 회전 칼리퍼스(Rotating Calipers) 알고리즘을 사용하여 최소 면적의 외접 사각형을 찾습니다.
Graham Scan 볼록 껍질 알고리즘
Graham Scan은 볼록 껍질을 계산하는 고전적인 알고리즘으로, 시간 복잡도는 입니다.
std::vector<Point> ConvexHull(std::vector<Point> points) { if (points.size() < 3) return points;
// 1. 가장 아래에 있는 점 찾기 (y가 가장 작고, x가 가장 작은 점) auto pivot = std::min_element(points.begin(), points.end(), [](const Point& a, const Point& b) { return a.y < b.y || (a.y == b.y && a.x < b.x); }); std::swap(points[0], *pivot); Point p0 = points[0];
// 2. 극각(Polar angle) 순으로 정렬 std::sort(points.begin() + 1, points.end(), [&p0](const Point& a, const Point& b) { float cross = CrossProduct(p0, a, b); if (std::abs(cross) < 1e-6f) { // 공선점일 경우 거리가 가까운 순으로 정렬 return DistanceSquared(p0, a) < DistanceSquared(p0, b); } return cross > 0; // 반시계 방향 });
// 3. 볼록 껍질 구축 std::vector<Point> hull; for (const auto& p : points) { // 시계 방향 전환을 유발하는 점 제거 while (hull.size() > 1 && CrossProduct(hull[hull.size()-2], hull[hull.size()-1], p) <= 0) { hull.pop_back(); } hull.push_back(p); }
return hull;}
// 외적: 회전 방향 판단float CrossProduct(const Point& o, const Point& a, const Point& b) { return (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);}회전 칼리퍼스 알고리즘
회전 칼리퍼스(Rotating Calipers) 알고리즘은 볼록 껍질의 모든 변을 순회하며 해당 변을 밑변으로 하는 외접 사각형의 면적을 계산합니다.
RotatedRect MinAreaRect(const std::vector<Point>& hull) { if (hull.size() < 3) return {};
float min_area = std::numeric_limits<float>::max(); RotatedRect best_rect;
int n = hull.size(); int right = 1, top = 1, left = 1; // 세 개의 '칼리퍼스' 위치
for (int i = 0; i < n; ++i) { int j = (i + 1) % n;
// 현재 변의 방향 벡터 float edge_x = hull[j].x - hull[i].x; float edge_y = hull[j].y - hull[i].y; float edge_len = std::sqrt(edge_x * edge_x + edge_y * edge_y);
// 단위 벡터 float ux = edge_x / edge_len; float uy = edge_y / edge_len;
// 수직 방향 float vx = -uy; float vy = ux;
// 가장 오른쪽 점 찾기 (변 방향 투영 최대) while (Dot(hull[(right + 1) % n], ux, uy) > Dot(hull[right], ux, uy)) { right = (right + 1) % n; }
// 가장 위쪽 점 찾기 (수직 방향 투영 최대) while (Dot(hull[(top + 1) % n], vx, vy) > Dot(hull[top], vx, vy)) { top = (top + 1) % n; }
// 가장 왼쪽 점 찾기 while (Dot(hull[(left + 1) % n], ux, uy) < Dot(hull[left], ux, uy)) { left = (left + 1) % n; }
// 사각형 크기 계산 float width = Dot(hull[right], ux, uy) - Dot(hull[left], ux, uy); float height = Dot(hull[top], vx, vy) - Dot(hull[i], vx, vy); float area = width * height;
if (area < min_area) { min_area = area; // 최적 사각형 파라미터 업데이트 best_rect.width = width; best_rect.height = height; best_rect.angle = std::atan2(uy, ux) * 180.0f / M_PI; // 중심점 계산... } }
return best_rect;}회전 칼리퍼스의 핵심 통찰은 밑변이 회전할 때 세 개의 ‘칼리퍼스’(가장 오른쪽, 위쪽, 왼쪽 점)가 단조 증가하며 전진할 뿐 후퇴하지 않는다는 점입니다. 따라서 총 시간 복잡도는 이 아닌 입니다.
최소 외접 회전 사각형
MinAreaRect 함수는 회전 칼리퍼스 알고리즘을 사용하여 최소 외접 회전 사각형을 계산합니다.
RotatedRect MinAreaRect(const std::vector<Point> &contour) { // 1. 점 개수를 줄이기 위해 서브샘플링 std::vector<Point> points = subsample_points(contour, kMaxBoundaryPoints);
// 2. 빠른 경로: 가로세로 비율이 높은 텍스트 박스는 직접 AABB 사용 float aspect = std::max(aabb_width, aabb_height) / std::max(1.0f, std::min(aabb_width, aabb_height)); if (aspect > 2.0f && points.size() > 50) { // 축 정렬 경계 상자 직접 반환 RotatedRect rect; rect.center_x = (min_x + max_x) / 2.0f; rect.center_y = (min_y + max_y) / 2.0f; rect.width = aabb_width; rect.height = aabb_height; rect.angle = 0.0f; return rect; }
// 3. 볼록 껍질 계산 std::vector<Point> hull = convex_hull(std::vector<Point>(points));
// 4. 회전 칼리퍼스: 볼록 껍질의 모든 변 순회 float min_area = std::numeric_limits<float>::max(); RotatedRect best_rect;
for (size_t i = 0; i < hull.size(); ++i) { // 현재 변을 기준으로 외접 사각형 계산 float edge_x = hull[j].x - hull[i].x; float edge_y = hull[j].y - hull[i].y;
// 모든 점을 변 방향과 수직 방향으로 투영 project_points_onto_axis(hull, axis1_x, axis1_y, min1, max1); project_points_onto_axis(hull, axis2_x, axis2_y, min2, max2);
float area = (max1 - min1) * (max2 - min2); if (area < min_area) { min_area = area; // 최적 사각형 업데이트 } }
return best_rect;}이 알고리즘의 시간 복잡도는 (볼록 껍질 계산) + (회전 칼리퍼스)입니다. 여기서 은 경계점 수입니다. 서브샘플링을 통해 을 200 이내로 제한하여 실시간 성능을 보장했습니다.
실시간 카메라 OCR: CameraX와 프레임 분석
실시간 OCR의 과제는 부드러운 프리뷰를 유지하면서 각 프레임을 최대한 빨리 처리하는 것입니다.
flowchart TB subgraph Camera["CameraX Pipeline"] direction TB CP[CameraProvider] PV[Preview UseCase<br/>30 FPS] IA[ImageAnalysis UseCase<br/>STRATEGY_KEEP_ONLY_LATEST] end
subgraph Analysis["프레임 분석 프로세스"] direction TB IP[ImageProxy<br/>YUV_420_888] BM[Bitmap 변환<br/>RGBA_8888] JNI[JNI 호출<br/>단일 언어 간 호출] end
subgraph Native["Native OCR"] direction TB DET[TextDetector<br/>~45ms GPU] REC[TextRecognizer<br/>~15ms/라인] RES[OCR 결과] end
subgraph UI["UI 업데이트"] direction TB VM[ViewModel<br/>StateFlow] OV[ResultOverlay<br/>Canvas 그리기] end
CP --> PV CP --> IA IA --> IP --> BM --> JNI JNI --> DET --> REC --> RES RES --> VM --> OVCameraX의 ImageAnalysis
CameraX는 Android Jetpack의 카메라 라이브러리로, 카메라 프레임을 실시간으로 분석할 수 있는 ImageAnalysis 유즈케이스를 제공합니다.
val imageAnalysis = ImageAnalysis.Builder() .setTargetResolution(Size(1280, 720)) .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) .build()
imageAnalysis.setAnalyzer(executor) { imageProxy -> val bitmap = imageProxy.toBitmap() val result = ocrEngine.process(bitmap) // UI 업데이트 imageProxy.close()}핵심 설정은 STRATEGY_KEEP_ONLY_LATEST입니다. 분석기의 처리 속도가 카메라 프레임 속도를 따라가지 못할 때, 이전 프레임을 버리고 최신 프레임만 유지합니다. 이를 통해 OCR 결과의 시의성을 보장합니다.
프레임 속도와 지연 시간의 균형
GPU 가속이 지원되는 기기에서(현재 제 손에 있는 스냅드래곤 870은 문제가 있는지 대부분의 계산을 GPU 가속에 맡기지 못하고 있습니다), PPOCRv5-Android는 이론적으로 높은 처리 속도에 도달할 수 있습니다. 하지만 그렇다고 모든 프레임을 처리해야 한다는 뜻은 아닙니다.
사용자가 카메라를 텍스트에 고정하고 있는 상황을 가정해 봅시다. 텍스트 내용은 짧은 시간 동안 변하지 않습니다. 매 프레임마다 전체 OCR을 수행하면 계산 리소스가 낭비됩니다.
하나의 최적화 전략은 ‘변화 감지’입니다. 화면에 현저한 변화가 있을 때만 OCR을 트리거하는 것입니다. 이는 연속된 프레임의 히스토그램이나 특징점을 비교하여 구현할 수 있습니다.
미래 전망: NPU와 양자화
온디바이스 AI의 미래는 NPU(Neural Processing Unit)에 있습니다. GPU와 비교하여 NPU는 신경망 추론을 위해 전용 설계되어 전성비(전력 대비 성능 비율)가 더 높습니다.
하지만 NPU의 과제는 파편화입니다. 칩 제조사마다 고유한 NPU 아키텍처와 SDK를 가지고 있습니다.
- Qualcomm: Hexagon DSP + AI Engine
- MediaTek: APU
- Samsung: Exynos NPU
- Google: Tensor TPU
Android의 NNAPI(Neural Networks API)가 통합 추상화 계층을 제공하려 노력하고 있지만, 실제 효과는 천차만별입니다. 많은 NPU 기능이 NNAPI를 통해 노출되지 않아 개발자는 제조사 특정 SDK를 사용해야 하는 경우가 많습니다.
INT8 양자화: 끝나지 않은 전투
FP16 양자화는 정확도를 거의 잃지 않는 보수적인 선택입니다. 하지만 극한의 성능을 추구한다면 INT8 양자화가 다음 단계입니다.
INT8 양자화는 가중치와 활성화를 32비트 부동 소수점에서 8비트 정수로 압축하며, 이론적으로 다음과 같은 이점을 제공합니다.
- 모델 크기 4배 압축
- 추론 속도 2~4배 향상 (하드웨어에 따라 다름)
- Qualcomm Hexagon DSP에서 10배 이상의 속도 향상 가능
이 유혹은 너무나 컸습니다. 그래서 저는 긴 INT8 양자화 여정을 시작했습니다.
첫 번째 시도: 합성 데이터 교정
INT8 양자화에는 양자화 파라미터(Scale 및 Zero Point)를 결정하기 위한 교정(Calibration) 데이터셋이 필요합니다. 처음에는 무작위로 생성된 ‘텍스트 유사’ 이미지를 사용했습니다.
# 잘못된 예시: 무작위 노이즈를 교정에 사용img = np.ones((h, w, 3), dtype=np.float32) * 0.9for _ in range(num_lines): gray_val = np.random.uniform(0.05, 0.3) img[y:y+line_h, x:x+line_w] = gray_val결과는 처참했습니다. 모델 출력값이 모두 0이었습니다.
Raw FLOAT32 output range: min=0.0000, max=0.0000Prob map stats: min=0.0000, max=0.0000, mean=0.000000양자화 도구가 무작위 노이즈를 바탕으로 잘못된 양자화 파라미터를 계산하여 실제 이미지의 활성화 값이 잘려 나갔기 때문입니다.
두 번째 시도: 실제 이미지 교정
ICDAR2015, TextOCR, PaddleOCR 공식 예제 이미지 등 실제 OCR 데이터셋 이미지를 사용하도록 변경했습니다. 동시에 Letterbox 전처리를 구현하여 교정 시의 이미지 분포가 추론 시와 일치하도록 했습니다.
def letterbox_image(image, target_size): """가로세로 비율을 유지하며 리사이즈하고 부족한 부분은 회색으로 채움""" ih, iw = image.shape[:2] h, w = target_size scale = min(w / iw, h / ih) # ... 중앙에 붙여넣기모델이 더 이상 0만 출력하지는 않았지만, 인식 결과는 여전히 깨진 글자였습니다.
세 번째 시도: C++ 측 타입 처리 수정
C++ 코드가 INT8 입력을 처리할 때 문제가 있음을 발견했습니다. INT8 모델은 원본 픽셀 값(0-255)을 기대하는데, 저는 여전히 ImageNet 정규화(평균 빼기, 표준편차 나누기)를 수행하고 있었습니다.
if (input_is_int8_) { // INT8 모델: 원본 픽셀을 직접 입력, 정규화는 첫 번째 계층에 통합됨 dst[i * 3 + 0] = static_cast<int8_t>(src[i * 4 + 0] ^ 0x80);} else { // FP32 모델: 수동 정규화 필요 // (pixel - mean) / std}동시에 하드코딩 대신 양자화 파라미터를 동적으로 읽어오는 로직을 구현했습니다.
bool GetQuantizationParams(LiteRtTensor tensor, float* scale, int32_t* zero_point) { LiteRtQuantization quant; LiteRtGetTensorQuantization(tensor, &quant); // ...}최종 결과: 타협
수일간의 디버깅에도 불구하고 INT8 모델은 여전히 정상적으로 작동하지 않았습니다. 원인은 다음과 같을 수 있습니다.
- onnx2tf의 양자화 구현: PP-OCRv5는 몇 가지 특수한 연산자 조합을 사용하는데, onnx2tf가 양자화 시 이를 올바르게 처리하지 못했을 수 있습니다.
- DBNet의 출력 특성: DBNet은 확률 맵을 출력하며 값의 범위가 0-1 사이입니다. INT8 양자화는 이러한 작은 범위의 값에 특히 민감합니다.
- 다단계 모델의 오차 누적: 검출과 인식 두 모델이 직렬로 연결되어 있어 양자화 오차가 누적되어 증폭됩니다.
두 번째 원인을 심층 분석해 보겠습니다. DBNet의 출력은 Sigmoid 활성화를 거쳐 값의 범위가 [0, 1]로 압축됩니다. INT8 양자화는 다음 공식을 사용합니다.
[0, 1] 범위의 값에 대해 scale이 적절히 설정되지 않으면, 양자화된 값이 INT8 범위 [-128, 127]의 아주 일부분만 차지하게 되어 심각한 정밀도 손실이 발생합니다.
# scale = 0.00784 (1/127), zero_point = 0이라고 가정# 입력 0.5 -> round(0.5 / 0.00784) + 0 = 64# 입력 0.1 -> round(0.1 / 0.00784) + 0 = 13# 입력 0.01 -> round(0.01 / 0.00784) + 0 = 1# 입력 0.001 -> round(0.001 / 0.00784) + 0 = 0 # 정밀도 손실!DBNet의 임계값은 보통 0.1-0.3으로 설정되는데, 이는 의미 있는 수많은 확률 값(0.1-0.3)이 양자화 후에는 13-38이라는 단 25개의 정수로만 표현된다는 것을 의미하며, 해상도가 심각하게 부족해집니다.
WARNING
PP-OCRv5의 INT8 양자화는 알려진 난제입니다. 시도 중이시라면 먼저 FP32 모델이 정상 작동하는지 확인한 후 단계별로 양자화 문제를 파악하시기 바랍니다. 또는 PaddleOCR을 더 잘 지원하는 PaddlePaddle 공식 Paddle Lite 프레임워크 사용을 고려해 보세요.
양자화 인식 훈련: 올바른 해결책
INT8 양자화를 반드시 사용해야 한다면, 올바른 방법은 훈련 후 양자화(Post-Training Quantization, PTQ)가 아닌 양자화 인식 훈련(Quantization-Aware Training, QAT)입니다.
QAT는 훈련 과정에서 양자화 오차를 시뮬레이션하여 모델이 저정밀도 표현에 적응하도록 학습시킵니다.
# PyTorch QAT 예시import torch.quantization as quant
model = DBNet()model.qconfig = quant.get_default_qat_qconfig('fbgemm')model_prepared = quant.prepare_qat(model)
# 정상 훈련, 하지만 순전파 중에 가짜 양자화 노드가 삽입됨for epoch in range(num_epochs): for images, labels in dataloader: outputs = model_prepared(images) # 양자화 시뮬레이션 포함 loss = criterion(outputs, labels) loss.backward() optimizer.step()
# 실제 양자화 모델로 변환model_quantized = quant.convert(model_prepared)아쉽게도 PP-OCRv5 공식 팀은 QAT 훈련된 모델을 제공하지 않습니다. 즉, 고품질의 INT8 모델을 얻으려면 처음부터 QAT 훈련을 진행해야 하며, 이는 이 프로젝트의 범위를 벗어납니다.
결국 저는 타협을 선택했습니다. INT8 + DSP 대신 FP16 양자화 + GPU 가속을 사용하기로 했습니다.
이 결정의 대가는 다음과 같습니다.
- 모델 크기가 INT8의 2배입니다.
- Hexagon DSP의 초저전력을 활용할 수 없습니다.
- 추론 속도가 이론적 최적치보다 2~3배 느립니다.
하지만 얻은 이점은 다음과 같습니다.
- 모델 정확도가 FP32와 거의 일치합니다.
- 개발 주기가 대폭 단축되었습니다.
- 코드 복잡도가 낮아졌습니다.
엔지니어링의 본질은 트레이드오프입니다. 때로는 ‘충분히 좋은 것’이 ‘이론적 최적’보다 더 중요합니다.
결론
PaddlePaddle에서 LiteRT까지, DBNet에서 SVTRv2까지, OpenCL에서 NEON까지, 온디바이스 OCR의 엔지니어링 실무는 딥러닝, 컴파일러, GPU 프로그래밍, 모바일 개발 등 여러 분야의 지식을 아우릅니다.
이 프로젝트의 핵심 교훈은 온디바이스 AI가 단순히 ‘모델을 휴대폰에 넣는 것’이 아니라는 점입니다. 다음이 필요합니다.
- 모델 아키텍처를 깊이 이해해야 올바르게 변환할 수 있습니다.
- 하드웨어 특성에 익숙해야 가속기를 충분히 활용할 수 있습니다.
- 시스템 프로그래밍을 마스터해야 고성능 네이티브 코드를 구현할 수 있습니다.
- 사용자 경험에 집중해야 성능과 전력 소비 사이의 균형을 찾을 수 있습니다.
PPOCRv5-Android는 오픈 소스 프로젝트로, 현대적인 OCR 모델을 실제 모바일 앱에 어떻게 배포하는지 보여줍니다. 이 글이 유사한 요구사항을 가진 개발자들에게 참고가 되기를 바랍니다.
Google이 LiteRT를 발표하며 말했듯이, “Maximum performance, simplified.”9 온디바이스 AI의 목표는 복잡함이 아니라 복잡한 것을 단순하게 만드는 것입니다.
후기
솔직히 말씀드리면, 저는 (업무와 관심 분야에서) Android를 떠난 지 최소 2년이 되었습니다. 그리고 이것은 제가 GitHub 부계정(떠나겠다는 결심의 표시로 본계정은 동료에게 넘겼습니다)에 처음으로 공개하는 비교적 성숙한 라이브러리입니다.
지난 몇 년간 제 업무 중점은 Android 분야가 아니었습니다. 구체적인 상황을 밝히기는 어렵지만, 나중에 기회가 되면 자세히 말씀드리겠습니다. 어쨌든 제가 Android에서 더 많은 성과를 내기는 어려울 것 같습니다.
이번 프로젝트 발표는 순전히 제 흥미 때문이었습니다. 현재 구축 중인 초기 Android 온디바이스 기반 도구의 아주 작은 밑바탕이 OCR이었고, 조만간 전체 소스 코드도 공개할 예정입니다.
여기까지 읽어주셔서 감사합니다. 제 저장소에 Star를 눌러주신다면 큰 힘이 될 것 같습니다. 감사합니다!
참고 문헌
Footnotes
-
Google AI Edge. “LiteRT: Maximum performance, simplified.” 2024. https://developers.googleblog.com/litert-maximum-performance-simplified/ ↩
-
PaddleOCR Team. “PaddleOCR 3.0 Technical Report.” arXiv:2507.05595, 2025. https://arxiv.org/abs/2507.05595 ↩
-
GitHub Discussion. “Problem while deploying the newest official PP-OCRv5.” PaddleOCR #16100, 2025. https://github.com/PaddlePaddle/PaddleOCR/discussions/16100 ↩
-
Liao, M., et al. “Real-time Scene Text Detection with Differentiable Binarization.” Proceedings of the AAAI Conference on Artificial Intelligence, 2020. https://arxiv.org/abs/1911.08947 ↩
-
Du, Y., et al. “SVTR: Scene Text Recognition with a Single Visual Model.” IJCAI, 2022. https://arxiv.org/abs/2205.00159 ↩
-
Du, Y., et al. “SVTRv2: CTC Beats Encoder-Decoder Models in Scene Text Recognition.” ICCV, 2025. https://arxiv.org/abs/2411.15858 ↩ ↩2
-
TensorFlow Blog. “Even Faster Mobile GPU Inference with OpenCL.” 2020. https://blog.tensorflow.org/2020/08/faster-mobile-gpu-inference-with-opencl.html ↩
-
ARM Developer. “Neon Intrinsics on Android.” ARM Documentation, 2024. https://developer.arm.com/documentation/101964/latest/ ↩
-
Google AI Edge. “LiteRT Documentation.” 2024. https://ai.google.dev/edge/litert ↩