AI Studio + Claude code 조합으로 의료 AI 앱 만들어본 후기

소개

시도하고자 했던 것과 그 이유를 알려주세요.

스마트폰으로 흉부 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)한테 "이 책 가져다주세요"라고 부탁하는 거예요. 선생님은 도서관에 갈 수 있으니까 대신 가져다주는 거죠.



4
2개의 답글
지피터스 뉴스레터

이번 주 AI, 딱 쓸 것만

매주 월요일 오후,
읽고 바로 써먹을 AI 소식만