Posts 시각 프롬프트 임베딩 기반 소수샷 객체 탐지 시스템 설계
Post
Cancel

시각 프롬프트 임베딩 기반 소수샷 객체 탐지 시스템 설계

학습 없이 새 대상을 찾게 만들기

객체 검출기는 학습할 때 정한 클래스만 찾는다. 현장에서 “이것도 찾아줘”라는 요구가 나오면 데이터를 모으고 라벨링하고 다시 학습해야 한다. 빨라도 며칠이다.

소수샷 검출(FSOD)은 이 과정을 없앤다. 운영자가 예시 이미지와 바운딩 박스를 하나 주면, 그것을 임베딩으로 바꿔 검출기가 곧바로 같은 대상을 찾기 시작한다.

이걸 실제 시스템으로 만들 때 부딪힌 것들을 정리했다.

구조 — 등록 사슬

1
2
3
4
5
6
7
8
9
10
11
12
등록 요청 (이미지 + 박스 + 클래스명)
      │  요청 수신부는 파싱해서 큐에 넣기만 한다
      ▼
슈퍼바이저 메인 루프
      │  이미지 로드 → 프롬프트 인코더로 임베딩 생성
      ▼
오브젝트 스토리지
      │  vpe/{vpe_id}/meta.json + {k}.npz
      │  갱신 통지 발행 {command: UPDATE, vpe_id}
      ▼
워커
      │  번들 로드 → 통합 텐서 구성 → 세대 증가 → 검출 시작

콜백에서 인코딩까지 하지 않고 큐에 넣기만 하는 것이 중요하다. 인코딩은 GPU를 쓰고 수백 밀리초가 걸린다. 메시지 콜백 안에서 하면 브로커 클라이언트의 수신 루프가 그동안 멈추고, 등록이 몰릴 때 뒤쪽 메시지가 통째로 유실된다.

저장 레이아웃이 계약이다

1
2
3
{bucket}/vpe/{vpe_id}/meta.json    # classes 배열 — 순서가 곧 class_id
{bucket}/vpe/{vpe_id}/0.npz        # classes[0]의 임베딩 (512-d, 키 "embedding")
{bucket}/vpe/{vpe_id}/1.npz        # classes[1]

여기서 규칙 하나가 반드시 필요하다. meta.json이 파일과 클래스 이름을 잇는 유일한 근거다. 파일 이름 순서에 의존하면 안 된다.

이유는 실패 방식에 있다. 순서가 어긋나도 오류가 안 난다. 0.npz1.npz가 뒤바뀌면 검출은 정상적으로 되고 클래스 이름만 뒤바뀐다. 화면에는 박스가 잘 그려지고 신뢰도도 높다. 사람이 눈으로 보고 이상하다고 느끼기 전까지 아무도 모른다.

식별자는 불투명하게 둔다

1
2
{scope-label}-{utc_compact}-{hex8}
예: U00001030-20260814T112359277543-feda8aac

앞의 두 조각은 사람이 로그에서 훑어보라고 넣은 것이지 파싱하라고 넣은 것이 아니다. 충돌 방지는 전적으로 마지막 32비트 난수가 한다.

이걸 명시해두지 않으면 누군가 접두어에서 스코프를 파싱하는 코드를 짜고, 그 스코프 라벨 형식이 바뀌는 순간 조용히 깨진다. 문서에 “불투명 식별자”라고 한 줄 적어두는 것이 나중의 며칠을 아낀다.

워커는 등록을 집합으로 든다

1
self._bundles: dict[str, Bundle]   # 키가 vpe_id

UPDATE는 대체가 아니라 추가/갱신이다. 등록할 때마다 쌓인다. 스코프로 대체되지 않는다.

이 설계 자체는 맞다. 여러 대상을 동시에 찾아야 하니까. 문제는 지우는 경로다. 삭제 명령을 상대 시스템과 합의하지 못한 상태였고, 그래서 실질적으로 재기동이 청소 역할을 하고 있었다.

이런 상태는 문서에 그대로 적어두는 편이 낫다. “삭제는 아직 합의 대기 중이고 현재는 재기동이 유일한 정리 수단이다”라고 써두면, 등록이 계속 쌓여 메모리가 늘어날 때 원인을 찾는 시간이 사라진다.

재기동하면 전부 멈추는 사각지대

설계는 복구를 주기적 HEALTH 발행에 맡기고 있었다. 슈퍼바이저가 30초마다 아는 모든 스코프에 HEALTH를 내고, 워커가 그걸 보고 다시 로드하는 구조다.

그런데 코드가 이랬다.

1
2
3
def tick(self):
    if not known:
        return 0        # ← 재기동 후 known이 비어 있으면 아무것도 안 낸다

known이 프로세스가 저장에 성공한 식별자 목록이고 메모리에만 있다.

HEALTH 복구는 워커 재시작만 구제하고 슈퍼바이저 재시작은 구제하지 못한다.

실제로 배포 재기동 후 등록 0건, 검출 발행 0건 상태로 3시간 이상 갔다. 아무 경고도 안 났다. 등록이 없으면 검출이 없는 것이 정상 동작이기 때문이다.

원인은 전역 인덱스 파일을 없앤 설계 결정이었다. 저장 레이아웃이 자기서술형(각 디렉터리가 자기 메타를 갖는)으로 바뀌면서 인덱스가 불필요해 보였는데, 그 인덱스가 사실은 “무엇이 등록돼 있었나”를 프로세스 밖에 남기는 유일한 수단이었다.

교훈은 이렇다. 메모리에만 있는 상태는 그 프로세스의 수명이 곧 그 상태의 수명이다. 복구 메커니즘을 설계할 때 “그 메커니즘 자체가 무엇에 의존하는가”를 한 단계 더 따라가 봐야 한다.

스토리지가 흔들리면 등록이 증발한다

오브젝트 스토리지가 뒤에서 초기화돼 버킷이 사라진 적이 있다. 그 뒤 정상적으로 들어온 등록 요청이 이렇게 됐다.

1
2
3
4
11:16:55  등록 요청 도착
11:16:55  vpe/…/0.npz 쓰기 실패: NoSuchBucket     시도 1/5
11:16:56 …58 …17:02                              시도 2,3,4/5
11:17:11  요청을 포기합니다 (시도 5회)            ← 16초 만에 버려짐

여기서 진짜 문제는 재시도 횟수가 아니었다. 실패 신호가 요청한 쪽으로 가지 않는다. 갱신 통지가 안 나갈 뿐이다. 그래서 운영자가 “방금 등록했어”라고 말해도 아무 일이 없었고, 그 사실을 아는 사람이 아무도 없었다.

비동기 등록 파이프라인을 만들 때 반드시 같이 설계해야 하는 것이 실패를 되돌려 보내는 경로다. 성공 통지만 있고 실패 통지가 없으면 “아무 응답 없음”이 성공인지 실패인지 구별되지 않는다.

검출 결과가 0건인 것이 정상인 시스템

이 시스템의 가장 어려운 특성이 이거였다.

시각 프롬프트가 등록되지 않으면 소수샷 검출기는 한 건도 발행하지 않는다. 그리고 그게 정상 동작이다.

일반 검출기라면 0건이 곧 이상 신호다. 소수샷은 아니다. 그래서 다음을 반드시 지표로 노출해야 한다.

지표없으면 생기는 일
현재 로드된 등록 수0건 발행이 정상인지 장애인지 구별 불가
마지막 등록 갱신 시각언제부터 멈춰 있었는지 알 수 없음
등록 요청 수신/성공/실패 건수실패가 상류인지 하류인지 못 가름
임베딩 세대 번호워커가 최신 등록을 물었는지 확인 불가

이 네 개가 없으면 “요청은 갔는데 검출이 안 된다”는 상황에서 확인할 것이 로그 전문 검색밖에 없다.

정리

소수샷 검출 자체는 모델이 해준다. 시스템 쪽에서 어려운 것은 전부 상태 관리였다.

  • 인코딩은 수신 경로 밖으로 뺀다
  • 파일과 이름의 대응은 명시적 메타로만 정한다. 순서 의존은 조용히 틀린다
  • 식별자는 불투명하다고 선언한다
  • 메모리에만 있는 복구 상태는 그 프로세스와 함께 사라진다
  • 실패를 되돌려 보내는 경로가 없으면 무응답과 성공이 구별되지 않는다
  • 0건이 정상일 수 있는 시스템은 “등록 상태” 자체를 지표로 내야 한다
This post is licensed under CC BY 4.0 by the author.