이 저장소가 대체 무엇인가.
보통 만화 뷰어 앱(Tachiyomi류 포함)은 페이지를 통째로 보여주고, 독자가 손가락으로 확대·이동하며 컷을 하나씩 찾아 읽어야 한다. 작은 폰 화면에서 빽빽한 만화 페이지를 읽을 때 이 핀치줌 과정은 은근히 피곤하다. Chika는 이 과정 자체를 온디바이스 머신러닝으로 자동화했다 — 페이지 이미지를 작은 YOLO(You Only Look Once) 객체 탐지 모델에 통과시켜 "여기부터 여기까지가 컷 1이다"를 스스로 계산하고, 독자는 화면 오른쪽을 톡 두드리기만 하면 카메라가 컷 1 → 컷 2 → … 순서로 자동으로 확대·이동한다.
비유하면: 기존 뷰어 = 지도 없이 스스로 길을 찾아야 하는 여행, Chika = 안내원이 팔을 잡고 "다음은 이쪽입니다"하고 이끌어주는 여행. 그리고 이 안내원은 서버에 물어보지 않는다 — 모델이 앱 안에 내장돼 있어(TFLite, 약 3~5MB급 소형 모델) 비행기 안에서도, 데이터가 없어도 똑같이 동작한다.
README의 자기소개는 "a panel-by-panel comic reader for Android — open a CBZ/CBR, and Chika finds the panels with an on-device ML model and guides you through them one tap at a time"다. 이름 Chika는 산스크리트/힌디어 Chitra Katha("그림 이야기")의 줄임말로, 브랜드명과 실제 코드의 패키지명(com.chakra.comicreader)이 다르다는 점이 흥미롭다 — 프로젝트 소유 조직이 Chakra(Chalchitra Krida)이고, "Chika"는 그 위에 얹은 제품/브랜드 이름이다.
겉보기엔 별 4개 리포지토리로는 소박해 보이지만(★14), 실제로는 Kotlin Multiplatform(KMP)으로 설계된 :shared 모듈, 완성도 높은 Swift 기반 iOS 앱(iosApp/), 그리고 자체 YOLO 모델 재학습 파이프라인(training/)까지 갖춘, 겉보다 훨씬 기술적으로 두꺼운 프로젝트다. 이 문서는 그 "겉보다 두꺼운" 부분 — 패널 탐지 알고리즘, 리딩 오더 계산, 병합/분할 플래너, 카메라 수학, KMP 구조 — 을 실제 소스코드 수준에서 파헤친다.
.cbz/.cbr로 바꾼 것이라, 내부적으로는 그냥 페이지 이미지(JPG/PNG 등)가 순서대로 들어있는 압축 파일이다. Chika는 이 압축을 풀어 페이지 이미지를 하나씩 꺼내 보여주는 뷰어이자, 그 위에 패널 탐지를 얹은 앱이다.트렌딩 이유 · 기존 CBZ 뷰어/Tachiyomi류 대비 차별점.
만화 뷰어 앱은 이미 시장에 넘친다. Tachiyomi(및 그 포크들), Perfect Viewer, YACReader 같은 앱들은 모두 "페이지를 잘 보여주는" 데 집중해왔다. Chika가 주목받는 이유는 이 문제를 다른 방향에서 풀었기 때문이다 — "페이지를 어떻게 보여줄까"가 아니라 "컷을 어떻게 순서대로 짚어줄까"를 온디바이스 ML로 자동화했다는 점, 그리고 그 위에 처음부터 KMP(Kotlin Multiplatform)로 안드로이드·iOS 양쪽에 같은 핵심 로직을 재사용하도록 설계했다는 점이다.
| 비교 대상 | 일반 CBZ/CBR 뷰어(Tachiyomi류) | Chika의 접근 |
|---|---|---|
| 컷 탐색 방식 | 사용자가 손가락으로 핀치줌·드래그하며 직접 찾음 | 온디바이스 YOLO가 컷 좌표를 계산, 탭 한 번마다 자동 이동 |
| 리딩 오더 | 없음(페이지 하나로 취급) | 패널을 행(위→아래)·열(읽기방향) 기준으로 자동 정렬 |
| 어색한 레이아웃 | 독자가 알아서 대응 | merge/divide 플래너가 너무 작은 컷은 합치고, 너무 큰 컷은 나눔 |
| 네트워크 의존 | 대개 서버 동기화·클라우드 라이브러리 전제 | 완전 오프라인 — 모델·아카이브·DB 전부 로컬 |
| 플랫폼 확장 | 안드로이드/iOS 별도 코드베이스가 보통 | KMP :shared로 핵심 로직(탐지 후처리·카메라 수학·아카이브 추상화)을 공유, iOS 앱도 이미 존재 |
| 탐지 신뢰도 처리 | 해당 없음 | 패널이 서로 과도하게 겹치면(테두리 없는 만화 등) 자동으로 전체 페이지 보기로 폴백 |
대부분의 뷰어는 페이지를 최소 단위로 취급한다. 폰 화면보다 훨씬 정보 밀도가 높은 만화 페이지(특히 액션 만화의 큰 스프레드나 세로로 긴 웹툰 원본 스캔)를 볼 때, 독자는 매번 확대→읽기→축소→다음 컷 위치로 이동을 반복해야 한다. 이건 "읽는다"기보다 "찾는다"에 가까운 인지적 부담이다.
패널을 정규화 좌표(Panel(left, top, right, bottom))로 미리 계산해두면, "다음 컷으로 이동"은 단순히 카메라가 그 좌표로 애니메이션(lerp)하는 문제가 된다. 독자는 위치를 찾을 필요 없이 방향(다음/이전)만 결정하면 된다. 게다가 모델이 실패해도(테두리 없는 만화, 겹친 컷) PanelReliability가 감지해 안전하게 전체 페이지 보기로 되돌아간다 — "틀린 자동화"보다 "정직한 수동 모드"를 택하는 설계다.
큰 신문을 스마트폰으로 찍어서 읽는다고 생각해보자. 일반 뷰어는 사진 한 장을 보여주고 알아서 확대하라고 한다. Chika는 마치 누군가 그 신문 기사마다 형광펜으로 순서를 매겨놓고, "1번 문단 다 읽었으면 다음 눌러, 그럼 2번 문단이 화면 가득 나올게"라고 안내하는 것과 같다. 그리고 이 형광펜 표시는 서버가 아니라 폰 안의 작은 AI가 그 자리에서 직접 그린 것이다.
또 하나 주목할 점은 라이선스 투명성이다. THIRD_PARTY_NOTICES.md는 번들된 TFLite 모델이 Manga109-s(학술 연구용 만화 데이터셋)로 학습됐다는 사실과, 이로 인해 상업적 배포 시 발생할 수 있는 라이선스 모호성(일부 해석은 CC BY-NC-SA로 봄)까지 스스로 문서화해 공개한다. 오픈소스 프로젝트가 흔히 얼버무리는 "학습 데이터 출처 문제"를 정면으로 인정하고 기록해둔 점은 신뢰도를 높이는 요소다.
KMP 2모듈(:shared/:app) + Compose UI + 온디바이스 ML + 아카이브 처리.
| 레이어 | 기술 / 버전 | 역할 |
|---|---|---|
| 언어 | Kotlin 2.2.10 | 전체 코드베이스(안드로이드 + shared) — iOS 쪽은 Swift로 별도 구현 |
| 멀티플랫폼 | Kotlin Multiplatform(KMP) — jvm + iosArm64/iosSimulatorArm64 | :shared 모듈이 순수 Kotlin 로직을 안드로이드·iOS 양쪽에 공급 |
| UI 프레임워크 | Jetpack Compose (Material 3, BOM 2024.12.01) | 선언형 UI — 리더/라이브러리 화면, 커스텀 Canvas 드로잉 |
| 비동기 | Kotlin Coroutines 1.9.0 | 페이지 디코딩·ML 추론을 Dispatchers.IO/Default에서 비블로킹 처리 |
| 로컬 DB | Room 2.7.0-alpha13 (+ KSP 컴파일러) | 라이브러리 목록·읽기 진행도(페이지+패널)를 SQLite 위에 영속화 |
| 온디바이스 추론 | TensorFlow Lite 2.16.1 | Manga109 학습 YOLO 모델(int8 양자화)을 로컬에서 실행 |
| ZIP(CBZ) 처리 | Apache Commons Compress 1.27.1 | JDK 기본 ZipFile보다 다양한 인코딩을 지원하는 랜덤 액세스 ZIP 리더 |
| RAR(CBR) 처리 | 7-Zip-JBinding-4Android (LGPL-2.1) | RAR4뿐 아니라 RAR5까지 읽는 네이티브 바인딩 — junrar 등 흔한 라이브러리는 RAR5를 못 읽음 |
| DI 방식 | 수동 DI — Application 서브클래스의 by lazy | Hilt/Koin 없이 ComicReaderApp이 싱글턴(DB·리포지토리·탐지기)을 직접 보유 |
| 빌드 시스템 | Gradle 9.2.1 (AGP) · Kotlin DSL(.kts) | settings.gradle.kts가 :app/:shared 2모듈을 등록 |
| 정적 배포 | fastlane + F-Droid 메타데이터 | Google Play 외에 F-Droid(오픈소스 앱스토어) 배포까지 대비한 메타데이터 보유 |
| CI/CD | GitHub Actions (ci.yml) | push/PR마다 안드로이드 디버그 APK + iOS 시뮬레이터 테스트 + 미서명 iOS IPA까지 3-트랙 빌드 |
| 모델 학습(별도 파이프라인) | Python + Ultralytics YOLO + PyTorch(MPS) | training/ — Apple Silicon(MPS)에서 커스텀 모델을 재학습해 TFLite로 export |
:shared의 commonMain에만 존재하고, 안드로이드는 Compose로, iOS는 SwiftUI(추정)로 각자 UI를 그린다.yolo26n 계열)를 쓴다.:shared(commonMain) ↔ :app(Android) 분리 + 패널 검출 5단계 파이프라인 + 리더 상태머신.
Chika의 뼈대는 "플랫폼에 안 묶이는 순수 로직"과 "플랫폼 전용 I/O"를 철저히 분리하는 것이다. shared/build.gradle.kts의 주석이 이 원칙을 명문화한다 — "commonMain에는 어떤 플랫폼(Android 등) API도 추가되어선 안 된다. TFLite/LiteRT, 비트맵 디코딩, 아카이브 백엔드 같은 플랫폼 통합은 이 인터페이스들 뒤, 즉 소비하는 타겟(:app, iosApp) 쪽에 있어야 한다"는 규칙이다.
ComicArchive 인터페이스는 :shared에 있지만, 실제 ZIP/RAR를 여는 구현체(ZipComicArchive, RarComicArchive)는 :app에만 있다. 마찬가지로 YoloPanelDecoder(원시 텐서를 Panel 리스트로 바꾸는 순수 함수)는 공유되지만, 실제 TFLite 인터프리터를 돌리는 MlPanelDetector는 안드로이드 전용이다. 그 덕분에 "같은 모델 파일 + 같은 디코더 로직 → 안드로이드와 iOS가 완전히 동일한 탐지 결과"를 보장한다 — YoloPanelDecoder의 주석이 명시적으로 "Android(TFLite)와 iOS(Core ML)가 같은 모델로 동일한 결과를 내도록"이라 적어둔 이유다.
PanelPipeline.zoomRegions() 하나가 전체 후처리를 오케스트레이션한다. 실제 흐름을 단계별로 따라가보자.
PanelGapFiller는 페이지를 64×64 그리드로 샘플링해 어떤 셀이 이미 패널로 덮였는지 표시한 뒤, 고전적인 "히스토그램에서 최대 직사각형 찾기" 알고리즘(스택 기반, O(n))을 행마다 반복 적용해 아직 덮이지 않은 영역 중 가장 큰 직사각형을 찾는다. 이 영역이 페이지 면적의 7% 이상이고 각 변이 12% 이상이면(얇은 여백/거터가 아니라 진짜 컷일 가능성이 높으면) 새 패널로 추가한다.
// PanelGapFiller.kt — 왜 "연결 요소"가 아니라 "최대 직사각형"을 쓰는가
// 페이지를 둘러싼 얇은 여백/거터 프레임은 모든 컷과 맞닿아 하나의 큰 "빈 영역"으로
// 연결돼 보이지만, 폭이 얇아 큰 직사각형을 이루지 못한다 → 자동으로 무시됨.
// 반면 진짜로 모델이 놓친 컷은 이웃 패널들에 둘러싸여 있어 깨끗한 직사각형으로 잡힌다.
fun fill(panels: List<Panel>, config: Config = Config()): List<Panel> {
val rect = largestEmptyRectangle(covered, n) ?: break
if (rect.cells * cellArea < config.minAreaFraction) break
// 폭·높이 모두 minSideFraction 이상이어야 "진짜 컷"으로 인정, 아니면 얇은 여백으로 간주해 버림
}
만화는 "행(위→아래)" 단위로 읽고, 행 안에서는 읽기 방향(왼쪽→오른쪽 또는 오른쪽→왼쪽, 일본 만화는 RTL)으로 읽는다. PanelOrdering.order()는 이걸 재귀적 X-Y 컷(recursive X-Y cut)으로 계산한다 — 먼저 수평선 하나로 패널들을 위/아래 두 그룹으로 딱 나눌 수 있는지 찾고(찾으면 그게 "행 구분선"), 안 되면 수직선으로 나눠보고(열 구분선), 그마저 안 되면(테두리가 삐뚤빼뚤하거나 큰 패널이 걸쳐 있으면) "위쪽 좌표 기준으로 대충 행을 묶는" 최후 수단을 쓴다.
핵심 트릭은 STRADDLE_TOLERANCE(0.25)다 — 패널이 살짝 삐뚤어져 컷 라인을 넘어가도(자기 길이의 25% 이내라면) "더 많이 걸친 쪽"으로 배정해 억지로 한 줄에 묶는다. 반대로 진짜로 두 행에 걸친 큰 세로 패널은 수평 컷을 아예 불가능하게 만들어서, 알고리즘이 자동으로 "수직 컷 먼저"로 전환한다 — 이게 "옆에 쌓인 작은 컷들과 나란히 있는 세로로 긴 큰 컷"을 사람이 읽는 순서와 똑같이 처리하는 방법이다.
YOLO가 찾아낸 원시 패널을 그대로 카메라 타겟으로 쓰면 너무 작은 컷(연속된 잔 컷 4~5개)이나 너무 큰 컷(스프레드, 페이지 전체 폭 패널)이 매번 튀어나와 읽기 리듬이 깨진다. PanelPlanner는 이를 두 가지 규칙으로 다듬는다.
| 상황 | 기준 | 동작 |
|---|---|---|
| 병합(merge) | 패널 면적 < 페이지의 10%(smallAreaFraction) + 서로 인접 | 최대 3개(maxMergeCount)까지 하나의 확대 영역으로 합침(합친 영역이 폭 55%·높이 45% 넘으면 중단) |
| 분할(divide) — 일반 | 면적 > 페이지의 35%(bigAreaFraction) + 정사각형이 아님 | 넓으면 세로 컷 1개, 높으면 가로 컷 1개 — 말풍선 그룹 사이 여백에 컷을 위치 |
| 분할 — 풀와이드 | 폭 ≥ 페이지의 85%(fullWidthFraction) | 납작하면 좌우 2분할, 세로로도 크면(broadHeightFraction ≥55%) 2×2 그리드 4분할 |
| 분할 — 스프레드 | 이미지 자체가 가로로 김(가로세로비 ≥ 1.15, 두 페이지가 나란히 스캔된 경우) + 패널이 양쪽 페이지에 걸침 | 페이지 이음매를 기준으로 4~8분할 그리드 — 두 페이지를 하나의 이어진 컷으로 취급 |
분할 컷의 위치는 무작정 중앙이 아니라 말풍선(bubble) 그룹 사이의 가장 큰 빈틈에 놓인다 — cutPosition()이 말풍선들의 좌표 구간을 정렬해 가장 넓은 간격의 중점을 찾고, 그게 중앙 대역(30~70%) 안에 있으면 채택, 없으면 그냥 중앙으로 자른다. 말풍선 하나를 반으로 잘라버리는 어색한 분할을 피하는 실용적인 휴리스틱이다.
일부 만화(특히 테두리 없는 다이내믹한 스프레드, Dandadan 같은 액션 신)는 애초에 "컷"이라는 개념이 흐릿하다. 이런 페이지에서 YOLO는 서로 심하게 겹치는 박스를 뱉어내는데, 이걸 그대로 카메라 타겟으로 쓰면 화면이 정신없이 튄다. PanelReliability.isReliable()은 패널 쌍끼리 겹치는 면적의 총합 ÷ 전체 패널 면적을 계산해, 이 값이 0.04를 넘으면(training/README.md가 "Dandadan에서 깨끗한 페이지는 ≈0.000, 혼란스러운 페이지는 ≥0.058로 실측 교정했다"고 명시) 패널 기반 자동 이동을 포기하고 전체 페이지 보기로 안전하게 폴백한다.
"틀린 자동화보다 정직한 수동 모드"— AI 기반 기능을 만들 때 자주 놓치는 원칙이다. 모델이 자신 없는 상황을 스스로 감지해 조용히 안전한 기본 동작(핀치줌 가능한 전체 페이지)으로 물러나는 것은, 사용자에게 "이 페이지는 자동 인식이 잘 안 됩니다"라고 에러를 띄우는 것보다 훨씬 매끄러운 실패 처리(graceful degradation)다.
ReaderViewModel은 "페이지"와 "패널"을 하나의 선형 슬롯(slot) 시퀀스로 통합한다. 슬롯 0은 페이지 전체를 보여주는 "인트로", 슬롯 1..n은 탐지된 패널들, 슬롯 n+1은 다시 페이지 전체를 보여주는 "아웃트로"다. 이 설계 덕분에 "다음"을 누르는 로직이 매우 단순해진다 — 그냥 슬롯 번호를 1 증가시키다가, 마지막 슬롯(아웃트로)을 넘어서면 다음 페이지의 슬롯 0으로 넘어간다.
// ReaderViewModel.kt — next()의 핵심 로직
fun next() {
val lastSlot = s.panels.size + 1 // n+1 = 아웃트로 슬롯
when {
s.slot < lastSlot -> setSlot(s.slot + 1) // 슬롯만 전진
s.pageIndex < s.pageCount - 1 -> goToPage(s.pageIndex + 1, targetSlot = 0) // 다음 페이지 인트로로
}
}
// currentCamera: 슬롯이 1..panels.size 범위면 그 패널, 아니면 FULL_PAGE(인트로/아웃트로)
val currentCamera: Panel
get() = if (slot in 1..panels.size) panels[slot - 1] else Panel.FULL_PAGE
패널 탐지는 지연 계산(lazy)이다 — 현재 페이지는 필요할 때 바로 탐지하고, 다음 페이지는 백그라운드로 미리 prefetch()해둔다. 탐지 결과는 panelCache: HashMap<Int, List<Panel>>에 페이지 인덱스로 캐싱돼, 뒤로 갔다 다시 오는 탐색이 재추론 없이 즉시 이뤄진다.
computePageDraw()는 "정규화된 카메라 사각형(어느 패널을 보여줄지)"을 받아 "실제 화면 픽셀 어디에, 얼마 크기로 페이지 비트맵을 그릴지"를 계산한다. 원리는 CSS의 object-fit: contain과 동일하다 — 카메라 영역이 화면에 다 보이도록(안 잘리게), 비율을 유지한 채 최대로 확대하고 중앙 정렬한다.
// CameraTransform.kt — contain-fit 스케일 계산
val scale = minOf(containerW / camW, containerH / camH) * fill // fill=0.98, 살짝 여백
val left = containerW / 2f - camCx * scale // 카메라 중심이 화면 중심에 오도록 평행이동
val top = containerH / 2f - camCy * scale
패널 사이를 이동할 때는 순간 이동이 아니라 Compose의 Animatable<Panel, AnimationVector4D>로 360ms 동안 부드럽게 보간(lerp)한다. 이때 Panel이 4개의 Float(left/top/right/bottom)로 이뤄진 데이터 클래스이기 때문에, TwoWayConverter로 Panel ↔ AnimationVector4D를 정의하기만 하면 Compose 애니메이션 시스템이 "사각형 자체"를 하나의 애니메이션 가능한 값으로 다뤄준다 — 좌표 4개를 각각 따로 애니메이션시키는 것보다 훨씬 깔끔한 설계다.
여기에 사용자가 손가락으로 핀치줌/드래그한 값(userScale, userPanX/Y)이 카메라의 자동 프레이밍 위에 추가로 곱해지고 더해진다. 수평 이동은 "커버(cover)" 방식으로 clamp(좌우로 배경이 보이지 않게 제한)하고, 수직 이동은 "배경 위에 떠 있는" 방식으로 clamp(최소 15%는 화면에 남도록 허용하되 완전히 놓치지는 않게)한다 — 이 비대칭 클램핑이 "좌우 스와이프 = 페이지 넘김 제스처"와 "상하 드래그 = 자유 패닝"을 서로 헷갈리지 않게 만드는 핵심이다.
ComicEntity는 lastPage와 lastSlot 두 컬럼을 함께 저장한다. 단순히 "몇 페이지까지 읽었나"가 아니라 "그 페이지의 몇 번째 컷까지 읽었나"까지 정확히 복원하는 것 — 앱을 껐다 켜도 정확히 보던 컷에서 이어 읽을 수 있다. ReaderViewModel.persistProgress()가 슬롯이 바뀔 때마다(setSlot(), 페이지 전환 시) 이 값을 갱신한다.
2개 Gradle 모듈 + iOS 앱 + 모델 학습 파이프라인.
| 파일/폴더 | 역할 |
|---|---|
shared/src/commonMain/.../detection/ | 핵심 중의 핵심 — 패널 탐지 후처리 전체. 이 폴더 7개 파일이 앱의 "지능"이다. |
app/.../detection/MlPanelDetector.kt | TFLite 인터프리터를 실제로 돌리는 유일한 곳. 첫 실행 시 텐서 shape를 로그로 남겨 디버깅을 돕는다. |
app/.../ui/reader/ReaderViewModel.kt | 슬롯 기반 상태머신 — "다음/이전"이 페이지 넘김인지 패널 이동인지를 여기서 판단. |
app/.../data/archive/RarComicArchive.kt | RAR5(최신 RAR 표준)까지 읽는 몇 안 되는 안드로이드 라이브러리 경로. junrar 등은 RAR4까지만 지원. |
training/README.md | "B&W Manga109로만 학습된 모델의 근본 원인을 고친다"는 목표와, "테두리 없는 만화는 데이터로도 못 고친다"는 한계까지 솔직히 기록한 R&D 로그. |
iosApp/Sources/LiteRTPanelDetector.swift | 안드로이드의 MlPanelDetector에 대응하는 iOS 구현 — 같은 .tflite 모델, 같은 YoloPanelDecoder 로직을 소비. |
GitHub 저장소명은 chika이고 앱 브랜드도 "Chika"지만, 실제 Kotlin 패키지·애플리케이션 ID는 com.chakra.comicreader다(레포 최상위 module name도 ComicBookReader). README가 설명하듯 "Chika/Chitra Katha" 브랜드 자산은 프로젝트 소유 조직 Chakra(Chalchitra Krida)가 보유하며 MPL-2.0 코드 라이선스와 별개다 — 브랜드 리네이밍이 코드 레벨 리팩터링보다 먼저 일어난 전형적인 사례로 보인다.
이 저장소 하나로 배울 수 있는 것 + 실습 아이디어.
:shared의 어떤 파일도 android.*나 Bitmap 같은 플랫폼 타입을 import하지 않는다. YoloPanelDecoder.decode()는 FloatArray와 IntArray만 받고, PageLoader가 만든 실제 Bitmap은 안드로이드 쪽 MlPanelDetector가 픽셀을 뽑아 FloatArray로 변환한 뒤에야 shared 코드에 넘겨진다. 이 경계선을 지키는 습관이 "테스트 가능한 순수 함수"와 "테스트하기 어려운 I/O 코드"를 분리하는 가장 실전적인 방법이다.
shared/src/commonMain/kotlin/com/chakra/comicreader/detection/PanelOrdering.kt를 열어 import 문이 하나도 없다는 것(외부 라이브러리 의존이 전혀 없는 순수 Kotlin)을 확인하자. 그다음 자신의 안드로이드 프로젝트에서 "테스트하고 싶은데 Context가 필요해서 유닛테스트가 힘든" 클래스 하나를 찾아, 순수 로직만 별도 클래스로 뽑아내는 리팩터링을 연습해본다.
많은 튜토리얼이 YOLO를 "모델 돌리면 바로 박스가 나온다"처럼 설명하지만, 실제로는 letterbox → 좌표 역변환 → NMS → 최소 면적 필터까지 다 손으로 구현해야 한다. YoloPanelDecoder는 이 전 과정이 약 150줄 안에 압축돼 있어, YOLO 계열 모델을 온디바이스에 통합할 때 실제로 무엇을 짜야 하는지 보여주는 좋은 참고 자료다. 특히 end-to-end 출력([1,N,6])과 raw 출력([1,4+nc,anchors]) 두 레이아웃을 모두 자동 판별하는 부분은, "export한 모델의 출력 형태가 버전마다 바뀐다"는 실무의 흔한 골칫거리를 어떻게 방어적으로 처리하는지 보여준다.
// YoloPanelDecoder.kt — 두 출력 레이아웃 자동 판별
val transposed = d1 < d2 // [1, attrs, anchors] 인지 [1, anchors, attrs] 인지 shape로 추론
val endToEnd = preds <= 1000 // 예측 개수가 적으면 NMS-free end-to-end로 간주
가짜 FloatArray 몇 개(직접 좌표를 채운 사각형 4~5개)를 만들어 suppress()(NMS) 함수만 떼어내 단위 테스트를 짜보자. IoU 임계값을 0.3, 0.45, 0.7로 바꿔가며 겹친 박스가 몇 개 살아남는지 관찰하면 NMS의 동작 원리가 체감된다.
ReaderScreen.kt의 pointerInput 블록은 탭 / 더블탭 / 드래그 / 핀치줌 / 스와이프 플릭 다섯 가지 제스처를 하나의 awaitEachGesture 루프 안에서 서로 충돌 없이 구분한다. 핵심은 pastSlop(터치 이동량이 "탭"과 "드래그"를 가르는 임계값을 넘었는지) 플래그와, VelocityTracker로 측정한 손가락 속도로 "천천히 끄는 드래그"와 "휙 넘기는 플릭"을 구분하는 것이다.
computePageDraw()와 clampPanHorizontal()/clampPanVertical() 세 함수만 따로 떼어 순수 Kotlin 파일로 옮기고, Compose 없이 콘솔에서 다양한 containerW/H, userScale 값을 넣어 결과 좌표를 출력해보자. UI 프레임워크와 "화면에 뭘 그릴지 계산하는 수학"을 분리하는 감각을 기를 수 있다.
대부분의 읽기 앱은 "마지막 페이지"만 저장한다. Chika는 lastPage + lastSlot 두 컬럼으로 페이지 안의 몇 번째 컷까지 저장한다. ComicDao.updateProgress()가 단일 UPDATE 쿼리로 두 값과 lastOpened 타임스탬프를 원자적으로 갱신하는 패턴도 눈여겨볼 만하다.
자신의 앱에 "다중 단계 진행 상태"(예: 동영상의 재생 위치 + 배속, 또는 설문의 페이지 + 문항 번호)를 Room 엔티티 하나에 여러 컬럼으로 저장하고, 앱을 강제 종료 후 재실행해 정확히 복원되는지 확인해보자.
ComicFormatDetector는 파일 확장자를 신뢰하지 않는다. 대신 파일의 첫 몇 바이트(매직 넘버) — ZIP은 "PK\x03\x04", RAR은 "Rar!\x1a\x07" — 를 직접 비교해 진짜 포맷을 판별하고, 매직이 없을 때만 확장자로 폴백한다. "실제로는 RAR인데 확장자만 .cbz로 잘못 붙은 파일"이 실사용에서 흔하기 때문에 나온 방어 코드다.
ComicFormatDetector.detect()를 그대로 복사해, 실제로 확장자를 바꿔치기한 테스트 파일(진짜 ZIP인데 이름만 .cbr인 파일)을 만들어 매직 바이트 판별이 확장자를 무시하고 올바르게 CBZ로 인식하는지 확인해보자.
스캔된 만화 페이지는 종종 2000~4000px급 고해상도라, 그대로 디코딩하면 힙이 쉽게 터진다. PageLoader.decodeSampled()는 BitmapFactory.Options.inSampleSize(2의 거듭제곱 배율로 다운샘플)를 이용해 maxPageDimension(2560px) 이하로만 디코딩하고, LruCache<Int,Bitmap>로 최근 페이지만 메모리에 남긴다. 캐시 크기 자체도 런타임의 최대 힙(Runtime.getRuntime().maxMemory())의 1/4로 자동 계산해, 저사양·고사양 기기 모두에서 합리적으로 동작하도록 설계했다.
computeInSampleSize() 함수를 그대로 가져와, 4000×6000 픽셀 이미지를 목표 크기 1024px로 줄일 때 inSampleSize가 몇으로 계산되는지 손으로 따라가보고, 실제로 그 배율만큼 파일 크기가 줄어드는지 BitmapFactory.decodeFile()로 검증해보자.
빌드는 Android Studio 하나로 끝 — 실기기 요구사항은 낮다.
| 항목 | 요구사항 |
|---|---|
| 런타임 대상(앱) | Android 8.0+(minSdk 26), targetSdk/compileSdk 36 |
| 빌드 JDK | JDK 21 권장(Android Studio 번들 JBR 사용), CI는 JDK 17로 컴파일(jvmTarget=17과 일치) |
| Android SDK | API 36 + Build-Tools 36.1.0 (Android Studio가 자동 관리) |
| Gradle/AGP | Gradle Wrapper 포함(./gradlew) · AGP 9.2.1 · Kotlin 2.2.10 |
| 빌드 명령(디버그 APK) | ./gradlew :app:assembleDebug |
| 빌드+설치(연결된 기기/에뮬레이터) | ./gradlew :app:installDebug |
| shared 모듈 유닛테스트 | ./gradlew :shared:jvmTest |
| iOS(선택, macOS 필요) | Xcode + CocoaPods + XcodeGen — iosApp/project.yml 기반, LiteRT/TFLite Swift는 Pod 전용 |
| 모델 재학습(선택) | Python + PyTorch + Ultralytics, Apple Silicon(MPS) 권장 — training/ 참고 |
| 실기기 하드웨어 | 일반적인 안드로이드 8.0+ 스마트폰이면 충분 — TFLite int8 모델은 CPU 4스레드로 추론(numThreads=4), 별도 GPU/NPU 불필요 |
Windows에 java가 PATH에 없으면 Gradle이 JDK를 못 찾아 실패한다. README가 안내하는 대로 PowerShell에서 Android Studio가 이미 갖고 있는 JBR(JetBrains Runtime)을 직접 가리키면 별도 설치 없이 바로 빌드된다:
$env:JAVA_HOME = "C:\Program Files\Android\Android Studio\jbr".\gradlew.bat :app:installDebug
CI(ci.yml)는 매 push/PR마다 3개 트랙을 동시에 검증한다 — ① 우분투 러너에서 :shared:jvmTest + :app:assembleDebug(안드로이드 디버그 APK), ② macOS 러너에서 iOS 시뮬레이터 대상 shared 모듈 컴파일 + 테스트, ③ macOS 러너에서 실기기용 미서명 iOS IPA 빌드까지. JDK 17로 고정해 api.foojay.io 같은 외부 툴체인 다운로드 없이 완전히 오프라인·결정적으로 빌드되도록 명시적으로 맞춘 점도 실무적으로 배울 만하다.
난이도별 5단계. 본인 소유이거나 자유 배포(퍼블릭 도메인) 만화로 실습할 것.
레포를 클론해 Android Studio로 열고, ./gradlew :app:installDebug(또는 Studio의 Run 버튼)로 연결된 기기나 에뮬레이터에 설치한다. 라이브러리 화면에서 파일 피커로 CBZ 하나를 임포트하고, 리더 화면에서 오른쪽/왼쪽 탭으로 컷이 넘어가는지 확인한다. 목표: "온디바이스 ML 앱"이 실제로 네트워크 없이 동작하는 걸 몸으로 확인(비행기모드로 테스트해보면 확실하다).
Android Studio의 Logcat에서 태그 MlPanelDetector로 필터링하면 panels=N bubbles=M planned=K 형태의 로그가 페이지를 넘길 때마다 찍힌다. 컷이 많은 페이지, 스프레드 페이지, 테두리 없는 페이지를 각각 넘겨보며 planned 값이 어떻게 달라지는지, 그리고 신뢰도가 낮은 페이지에서 전체 페이지 보기로 자동 폴백되는지 관찰한다. 목표: PanelReliability의 겹침도 임계값(0.04)이 실제로 어떤 페이지에서 발동하는지 데이터로 체감.
PanelPlanner.Config의 smallAreaFraction(현재 0.10)을 0.20으로, maxMergeCount(현재 3)를 5로 바꾸고 :shared:jvmTest를 다시 돌려 어떤 기존 테스트가 깨지는지 확인한다. 그다음 PanelPlannerTest.kt의 기존 케이스를 참고해, 바뀐 값에 맞는 새로운 기대 결과로 테스트를 고쳐 써본다. 목표: 하이퍼파라미터 하나가 실제 UX(컷이 얼마나 자주 합쳐지는지)에 어떤 영향을 주는지, 그리고 회귀 테스트가 이런 변화를 어떻게 잡아내는지 이해.
HuggingFace의 leoxs22/manga-panel-detector-yolo26n 페이지에서 다른 양자화 버전(또는 training/scripts/export_tflite.py로 직접 재학습한 모델)을 받아 app/src/main/assets/manga_panel_detector_int8.tflite를 교체한다. MlPanelDetector의 init 로그(입력/출력 텐서 shape)가 기존과 같은지 확인하고, 다르면 YoloPanelDecoder가 자동으로 레이아웃을 판별하는지(또는 실패하는지) 관찰한다. 목표: "모델 파일만 바꾸면 되는 아키텍처"가 실제로 얼마나 잘 방어적으로 짜였는지 검증.
PanelOrdering.kt의 "no clean cut" 폴백 경로(스트래들 허용으로도 못 자르는 경우)를 겨냥한 새 테스트 케이스를 PanelOrderingTest.kt에 추가해보자 — 예를 들어 큰 패널 하나가 작은 패널 3개와 부분적으로 겹치는 인위적 좌표를 만들어, cut()이 최종적으로 "위쪽 좌표 기준 행 클러스터링" 분기로 빠지는지 확인. 통과하면 PanelPipeline 레벨의 통합 테스트로 한 단계 더 올려, gap-fill → ordering → planner 전체가 예상한 순서를 내는지도 검증해본다. 목표: 순수 Kotlin 도메인 로직에 대한 유닛테스트 작성 감각과, KMP의 commonTest가 JVM/iOS 양쪽에서 동시에 실행된다는 것을 실제로 확인.
이 저장소를 출발점으로, 6주 코스.
| 주차 | 주제 | 학습 자료 |
|---|---|---|
| 1주차 | Kotlin·Jetpack Compose 기초 | Android 공식 Compose 튜토리얼 · ui/reader/ReaderScreen.kt 정독 · 실습 1 |
| 2주차 | Kotlin Multiplatform(KMP) | JetBrains 공식 KMP 문서 · shared/build.gradle.kts와 commonMain/commonTest 구조 분석 |
| 3주차 | 온디바이스 ML·TFLite 기초 | TensorFlow Lite 공식 가이드 · MlPanelDetector.kt+YoloPanelDecoder.kt 정독 · 실습 2·4 |
| 4주차 | 객체 탐지 후처리(NMS·좌표 변환) | YOLO 논문/Ultralytics 문서의 NMS 설명 · PanelGapFiller의 최대 직사각형 알고리즘을 LeetCode식으로 다시 풀어보기 |
| 5주차 | 이미지 파이프라인·메모리 관리 | PageLoader.kt의 다운샘플/LRU 캐시 분석 · Android BitmapFactory 공식 문서 · 실습 5 |
| 6주차 | 제스처·애니메이션·좌표 수학 | CameraTransform.kt+ReaderScreen.kt의 pointerInput 재구현 · Compose Animatable/TwoWayConverter 문서 · 실습 3 |
본문에 나온 용어 빠른 참조.
| 용어 | 의미 |
|---|---|
| KMP (Kotlin Multiplatform) | 하나의 Kotlin 코드베이스를 JVM(안드로이드)·iOS 등 여러 플랫폼에서 컴파일해 로직을 공유하는 기술. Chika의 :shared 모듈이 이 방식으로 안드로이드·iOS 양쪽에 로직을 공급한다. |
| commonMain | KMP 모듈에서 모든 타겟 플랫폼이 공유하는 소스셋. 플랫폼 전용 API를 쓸 수 없는 순수 Kotlin 코드만 들어간다. |
| Jetpack Compose | 안드로이드의 선언형 UI 툴킷. XML 레이아웃 대신 Kotlin 함수(@Composable)로 UI를 기술한다. |
| TFLite (TensorFlow Lite) | 모바일/임베디드 기기에서 신경망을 가볍게 실행하도록 만든 TensorFlow의 경량 런타임. Chika는 int8 양자화 모델을 Interpreter로 돌린다. |
| YOLO (You Only Look Once) | 이미지를 한 번의 신경망 순전파로 훑어 바운딩 박스+클래스+신뢰도를 동시에 뽑는 실시간 객체 탐지 모델 계열. |
| NMS (Non-Maximum Suppression) | 겹치는 여러 탐지 박스 중 신뢰도가 가장 높은 것만 남기고 중복을 제거하는 후처리 알고리즘. IoU(교집합/합집합) 임계값으로 "겹침"을 판단한다. |
| Manga109 | 일본 만화 109편으로 구성된 학술 연구용 이미지 데이터셋. Chika의 번들 모델이 이 데이터로 학습돼, 상업적 배포 시 라이선스 고지 의무가 따른다. |
| CBZ / CBR | 만화 이미지를 각각 ZIP/RAR로 압축한 디지털 만화 파일 포맷. 확장자만 다를 뿐 내부는 그냥 이미지 파일 모음이다. |
| RAR5 | RAR 압축 포맷의 최신 버전. junrar 같은 흔한 라이브러리는 지원하지 않아, Chika는 7-Zip-JBinding으로 우회한다. |
| Room | 안드로이드 공식 SQLite 추상화 라이브러리. 어노테이션(@Entity, @Dao)으로 스키마와 쿼리를 타입 안전하게 정의한다. |
| LRU 캐시 | Least Recently Used — 캐시가 꽉 차면 가장 오래 안 쓰인 항목부터 버리는 캐시 교체 정책. Chika의 PageLoader가 디코딩된 페이지 비트맵을 이 방식으로 관리한다. |
| Letterbox | 원본 이미지 비율을 유지한 채 정사각형(또는 다른 비율) 입력 크기에 맞추고, 남는 공간을 여백(패딩)으로 채우는 전처리 기법. YOLO의 표준 입력 방식이다. |
| 카메라 lerp (Linear Interpolation) | 두 값(여기선 사각형 좌표) 사이를 매끄럽게 보간하는 애니메이션 기법. Chika는 Panel ↔ AnimationVector4D 변환기로 패널 간 카메라 이동을 Compose Animatable로 애니메이션한다. |
| MPL-2.0 (Mozilla Public License 2.0) | 파일 단위 카피레프트 라이선스. GPL보다 약한 강제성(파생 저작물 전체가 아니라 수정한 파일만 공개 의무)을 가져 앱스토어 배포와 호환된다. |