소개
시도하고자 했던 것과 그 이유를 알려주세요.
스마트폰으로 흉부 X-ray를 찍으면 AI가 질환을 분석해주는 모바일 웹앱을 만들어봤습니다.
병원에서 모니터에 띄운 X-ray를 빠르게 확인하고 싶을 때가 있는데, 스마트폰 하나로 간단히 AI 분석을 받을 수 있으면 좋겠다 싶었어요. Google의 CXR Foundation 모델이 Zero-shot으로 질환 분류가 된다길래, 이걸 모바일에서 쓸 수 있게 만들어보자 해서 시작했습니다.
진행 방법
어떤 도구를 사용했고, 어떻게 활용하셨나요?
영역
기술
Frontend
React + Vite + TailwindCSS
Backend
FastAPI + Python
AI Model
Google CXR Foundation (ELIXR + QFormer)
Image Processing
OpenCV, CLAHE
Deployment
Cloudflare Tunnel
AI 개발 도구
Google AI Studio, Claude
CXR Foundation 모델
Google이 공개한 흉부 X-ray용 Foundation 모델입니다. ELIXR(이미지 인코더)와 QFormer(텍스트-이미지 브릿지)가 조합된 구조예요.
대규모 X-ray 데이터로 사전 학습되어 있어서, 질환명을 텍스트 레이블로 지정하면 별도 학습 없이 Zero-shot으로 예측이 됩니다.
전체 흐 름
스마트폰을 사용하는 과정을 보여주는 다이어그램
프론트엔드 개발
프론트엔드는 Google AI Studio에서 초안을 먼저 뽑았습니다. 거기서 나온 파일을 다운받아서 VS Code에 넣고, Claude한테 "코드베이스 분석하고 리팩토링 해줘"라고 했더니 꽤 깔끔하게 정리가 됐어요.
지능형 독소 감지라는 단어가 포함된 웹사이트의 스크린샷
Azure 웹 브라우저의 스크린샷
X-ray 영역 인식
처음에는 Gemini API로 X-ray 영역을 자동 인식하고, OpenCV 엣지 검출로 크롭을 자동화하려고 했습니다.
response = gemini.generate_content([
"이 이미지에서 X-ray 영역의 4개 꼭짓점 좌표를 찾아줘",
image
])
edges = cv2.Canny(image, 50, 150)
contours = cv2.findContours(edges, ...)
근데 모니터 반사나 조명 때문인지 인식률이 너무 안 나와서, 결국 사용자가 직접 4점을 찍는 방식으로 바꿨습니다. 문서 스캐너 앱들이 이렇게 하는 이유가 있더라고요.
이미지 크롭 & 원근 보정
4점이 지정되면 OpenCV로 원근 보정을 합니다.
src_points = np.float32(corners)
dst_points = np.float32([[0, 0], [width, 0], [width, height], [0, height]])
matrix = cv2.getPerspectiveTransform(src_points, dst_points)
warped = cv2.warpPerspective(image, matrix, (width, height))
비스듬히 찍어도 정면처럼 펴집니다.
CLAHE 전처리
모바일로 찍은 이미지가 품질이 안 좋아서 여러 전처리를 시도해봤습니다. 히스토그램 평활화, 감마 보정, 샤프닝 등등... 근데 대부분 오히려 더 이상해져서, 결국 CLAHE만 남겼습니다.
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
enhanced = clahe.apply(grayscale_image)
Zero-shot Classification
질환 레이블을 텍스트로 정의하고, 이미지 피처와의 유사도를 계산해서 분류합니다.
labels = [
"normal chest x-ray",
"pneumonia",
"cardiomegaly",
"pleural effusion",
...
]
image_features = model.encode_image(preprocessed_image)
text_embeddings = model.encode_text(labels)
similarity = image_features @ text_embeddings.T
probabilities = softmax(similarity * temperature)
레이블만 바꾸면 다른 질환도 예측할 수 있어서 편했습니다.
GradCAM Heatmap
이상 소견 위치를 시각화하려고 했는데, 처음에는 QFormer 토큰을 공간 그리드로 재해석하는 방식을 시도했습니다. 근데 QFormer는 텍스트 연결용이라 공간 정보가 섞여있어서 잘 안 됐어요.
그래서 ELIXR의 중간 feature map을 직접 써서 GradCAM을 구현했더니 훨씬 나았습니다.
intermediate_output = model.get_intermediate_layer(image)
gradients = tape.gradient(loss, intermediate_output)
weights = tf.reduce_mean(gradients, axis=(1, 2))
cam = tf.reduce_sum(weights * intermediate_output, axis=-1)
heatmap = cv2.resize(cam, (original_width, original_height))
배포
Cloudflare Tunnel을 쓰면 로컬 서버를 외부에서 접속할 수 있게 해줍니다. 별도 서버 없이 스마트폰으로 테스트하기 좋았어요.
그리고 Frontend, Backend, Tunnel을 매번 따로 실행하기 귀찮아서 스크립트 하나로 묶었습니다.
#!/bin/bash
# start_pipeline.sh
cd backend && python server.py &
cd frontend && npm run dev &
cloudflared tunnel --url http://localhost:3000 &
echo "스마트폰으로 터널 URL 접속하세요"
프로젝트 구조
cxr-scanner/
├── frontend/
│ ├── App.tsx
│ ├── components/
│ │ ├── Camera.tsx
│ │ ├── CropView.tsx
│ │ └── AnalysisView.tsx
├── backend/
│ └── server.py
├── models/
│ └── cxr_foundation.py
└── scripts/
└── start_pipeline.sh
결과와 배운 점
배운 점
AI Studio로 초안 만들고 Claude로 리팩토링하는 방식이 저한테는 잘 맞았습니다.
자동화가 잘 안 되면 반자동화로 빠지는 것도 방법인 것 같아요.
전처리 여러 개 쌓는 것보다 하나만 쓰는 게 나을 때도 있었습니다.
시행착오
시도
결과
최종
Gemini + OpenCV 자동 영역 인식
인식률 실패
수동 4점 지정
여러 이미지 전처리
더 이상해짐
CLAHE만 사용
QFormer 토큰으로 Heatmap
공간 정보 섞임
ELIXR GradCAM
localhost:8000 직접 호출
모바일에서 실패
Vite Proxy (/api)
앞으로 해보고 싶은 것
나이/성별 추정 같은 추가 기능 - 안되더라구요 ㅠㅡㅠ
PWA로 오프라인 지원
CT나 MRI로 확장
도움 받은 글
Google CXR Foundation 모델 공식 문서
OpenCV Perspective Transformation 튜토리얼
추가할 내용 (배포 섹션 확장)
Vite Proxy로 모바일 접속 문제 해결
Cloudflare Tunnel로 외부 접속은 됐는데, 모바일에서 API 호출이 안 되는 문제가 있었습니다.
원인은 프론트엔드 코드에서 백엔드를 localhost:8000으로 호출하고 있었기 때문이에요. PC에서는 문제없지만, 스마트폰 입장에서 localhost는 스마트폰 자신을 가리키니까 당연히 안 되죠.
[스마트폰] → [Cloudflare Tunnel] → [localhost:3000 Frontend]
↓
fetch('localhost:8000') ← 여기서 실패!
(스마트폰의 localhost를 찾음)
해결책은 Vite Proxy입니다. Vite 개발 서버가 중간에서 API 요청을 대신 전달해주는 거예요.
// vite.config.ts
export default defineConfig({
server: {
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
});
이제 프론트엔드에서 localhost:8000/analyze 대신 /api/analyze로 호출하면, Vite 서버가 받아서 백엔드로 전달합니다.
[스마트폰] → [Tunnel] → [Vite:3000] → /api/analyze
↓
[Backend:8000] /analyze ← 성공!
쉽게 말하면, 학생(스마트폰)이 직접 도서관(백엔드)에 갈 수 없을 때, 선생님(Vite)한테 "이 책 가져다주세요"라고 부탁하는 거예요. 선생님은 도서관에 갈 수 있으니까 대신 가져다주는 거죠.