Post

스탯창 이미지 분석

스탯창 이미지 분석

TAB 스탯창 이미지 분석 기능 추가

친구들이랑 오버워치를 하면 TAB을 눌러 나오는 스탯창을 자주 보게된다.

그래서 그 스탯창 스크린샷을 올리면 영웅별 K/D/A, 피해량, 치유량을 분석해서 누가 얼마나 잘했는지 코치 피드백을 받을 수 있게 만들었다.

이번 글에서는 이 기능을 어떻게 구현했는지, 그리고 함께 개선된 채팅 로직과 Admin 기능을 정리한다.





1. TAB 스탯창 이미지 분석 (vision_stats.py)

전체 파이프라인

분석은 크게 두 단계로 나뉜다.

영웅과 팀 구분은 OpenCV로, 수치(K/D/A·피해량·치유량·경감량) 읽기와 코치 피드백 생성은 Gemini로 처리한다.

1
2
3
4
5
6
7
8
9
10
11
이미지 입력
  ↓
1단계 (coarse): HSV 색상으로 스탯창 영역 대략 검출
  ↓
2단계 (fine): 정밀 팀 패널 검출 (파란팀/빨간팀)
  ↓
행별 영웅 아이콘 인식 (OpenCV 템플릿 매칭)
  ↓
Gemini: 수치 읽기 + 팀 피드백 + 개인 피드백 생성
  ↓
사용자에게 보여줄 마크다운 표 + 관리자 전용 진단 로그 분리



팀 구분: 파란팀 = 아군, 빨간팀 = 상대

오버워치 TAB 스탯창은 아군이 파란 배경, 상대가 빨간 배경으로 나온다.

OpenCV의 HSV 색공간에서 파란/빨간 색상 범위로 마스크를 만들어 각 팀 패널의 위치를 찾는다.

1
2
BLUE_HUE_RANGE = (95, 135)
RED_HUE_RANGES = [(0, 10), (115, 179)]

사용자는 항상 아군(파란팀)에 속한다는 전제를 코드에 명시했다.



전체화면 캡처 대응 (2단계 검출)

처음에 표만 캡처한 이미지는 인식이 잘 됐는데, 전체 화면을 캡처하면 스탯창이 화면의 일부만 차지해서 인식에 실패했다.

이 문제를 해결하기 위해 2단계 검출 방식을 썼다.

1단계(coarse)에서는 완화된 색 조건으로 스탯창이 있는 영역을 대략 찾아서 sub-image로 잘라낸다.

2단계(fine)에서는 잘라낸 sub-image에 정밀 파이프라인을 적용한다.

1
layout = _compute_team_layout_with_coarse_crop(cv2, np, image)

이렇게 하면 표 기준으로 잘라낸 것과 전체화면 캡처 모두 같은 파이프라인으로 처리할 수 있다.



영웅 인식: 역할 고정 순서 규칙

영웅 아이콘 인식이 생각보다 까다로웠다.

TAB 스탯창에서는 영웅 초상화에 팀 색(파랑/빨강) 틴트가 오버레이되기 때문에 이미지 템플릿과 색상이 달라 매칭이 잘 안 됐다.

그래서 두 가지 규칙을 적용했다.

첫 번째는 행 순서 고정이다. 오버워치2 스탯창은 역할 고정(Role Lock) 모드라 항상 같은 순서로 정렬된다.

1
2
ROW_ROLES = ["탱커", "딜러", "딜러", "힐러", "힐러"]
ROW_ROLE_CODES = ["tank", "damage", "damage", "support", "support"]

1행은 반드시 탱커, 2 ~ 3행은 딜러, 4 ~ 5행은 힐러다.

이 규칙을 알면 영웅 아이콘이 잘 인식되지 않아도 역할은 확정할 수 있고, 후보 영웅 수를 역할 내로 좁혀서 매칭 정확도를 높일 수 있다.


두 번째는 실제 스탯창 crop을 템플릿으로 사용하는 것이다.

공식 홍보 아트보다 실제 게임 화면에서 잘라낸 이미지가 그레이스케일 비교 정확도가 더 높았다.



핸드폰 사진은 지원하지 않음

처음에 핸드폰으로 찍은 사진을 테스트했는데 인식이 전혀 안 됐다.

카메라로 찍으면 원근 왜곡, 색상 편차, 해상도 차이 등 변수가 너무 많아서 현재 파이프라인으로는 처리할 수 없다.

그래서 이미지 업로드 버튼을 누르면 “컴퓨터로 캡처한 스크린샷만 지원한다”는 안내문이 먼저 나오게 했다.



피드백 생성

수치 인식 후 Gemini가 두 종류의 피드백을 생성한다.

팀 피드백은 우리팀 전체의 교전 흐름 요약, 잘한 점, 아쉬운 점, 다음 판 개선 방향으로 구성된다.

개인 피드백은 본인 행이 인식된 경우에만 생성되며, 본인 영웅의 역할과 스탯을 상대팀 같은 역할과 비교해서 분석해준다.


또, 피드백 프롬프트에 영웅 특성 판단 기준을 명시했다.

예를 들어 방벽 스킬이 없는 영웅의 경감량 0은 약점이 아니라 구조적으로 정상인 수치인데 이걸 모르면 엉뚱한 지적이 나온다.

“생존력이 뛰어난 영웅임에도 0데스”처럼 특성과 결과가 모순되게 쓰는 것도 금지했다.



사용자 화면과 관리자 진단 정보 분리

analyze_scoreboard_image()는 두 가지를 반환한다.

report는 사용자에게 그대로 보여줄 마크다운이고

admin_log는 팀 패널 좌표, 행별 crop 이미지 경로, 영웅 인식 유사도 점수 같은 진단 정보다.

진단 정보는 ChatLog.metadata에만 저장되고 사용자 응답에는 절대 포함하지 않는다.



세션 연동

스탯창 분석 결과를 이후 채팅에서도 활용할 수 있도록 세션에 저장한다.

1
2
3
4
5
6
7
8
9
# views.py
if enemy_team:
    conversation_context["enemy_team"] = enemy_team
if ally_team:
    conversation_context["ally_team"] = ally_team
if my_stats:
    conversation_context["my_stats"] = my_stats
# 세션 타임아웃 방지
conversation_context["last_message_ts"] = time.time()

스탯창 분석 직후 “상대 탱커 어떻게 상대해?”라고 물으면 이미 인식된 아군/상대 조합 정보를 그대로 이어받아 답변한다.

my_stats가 세션에 있으면 chatbot_graph.infer_current_hero()가 본인 영웅을 자동으로 확정하기 때문에

후속 질문에서 역할을 다시 물어보지 않는다.






2. 채팅 로직 개선 (chatbot_graph.py)

팀 조합 평가 질문 구분

“둠피 메르시 조합 어때?”처럼 아군 조합 자체를 묻는 질문(composition)은 사용자 자신의 역할과 무관하게 평가할 수 있다.

기존에는 이런 질문도 current_hero를 모르면 역할부터 물어봤는데, 현재 영웅의 플레이를 질문한 것이 아니므로 수정했다.

1
2
3
4
5
# composition은 역할 질문 대상에서 제외
ROLE_CLARIFICATION_INTENTS = {
    "performance_improve", "stay", "swap", "general", "counter", "situation",
}
# "composition"은 없음 — 역할 몰라도 바로 답할 수 있다


스탯창 분석으로 my_team_stats를 이미 알고 있는 경우도 역할 되묻기를 건너뛴다.

1
2
if state.get("my_team_stats"):
    return False  # 스탯 데이터가 있으면 역할 없어도 의미 있는 답 가능



생략형 후속 질문 컨텍스트 이어받기

“어떻게 플레이해?”, “궁은 언제 써?” 같은 영웅 이름 없는 후속 질문은 이전 맥락을 이어받을 수 있다.

반면 “조합 추천해줘”처럼 완전히 새로운 주제의 질문은 이전 맥락을 이어받으면 안 된다.

1
2
3
4
5
6
7
8
_ELLIPSIS_FOLLOWUP_WORDS = ["플레이", "운영", "스킬", "포지션", "타이밍", "", "굴리", "굴려"]

def is_ellipsis_followup(text: str) -> bool:
    if find_all_heroes(text):
        return False  # 영웅이 있으면 생략형이 아니다
    if find_map(text):
        return False  # 특정 맵을 언급하면 완결된 질문이다
    return any(word in text for word in _ELLIPSIS_FOLLOWUP_WORDS)

이전 intent는 생략형 후속 질문일 때만 이어받는다.



아군/상대 조합 인식 오류 수정

“상대팀은 A B C이고 우리팀은 D E F야”처럼 한 문장에 두 팀을 쓰면

상대팀 캡처 그룹이 “우리팀” 마커까지 삼켜버려 아군 영웅이 상대팀에 섞이는 문제가 있었다.

이번에 교차 오염 방지 로직을 강화했고 아군/상대 조합을 구분하는 새로운 패턴들을 추가했다.

1
2
3
4
5
6
7
8
9
10
11
12
# 아군으로 인식하는 패턴들
def find_ally_complaint_hero(text):
    # "메르시가 힐을 안 해줘" → 메르시는 아군
    ...

def find_synergy_ally_heroes(text):
    # "윈스턴이랑 같이 쓰면 어때?" → 윈스턴은 아군 후보
    ...

def find_self_comparison_heroes(text):
    # "솜브라랑 트레이서랑 둘 다 고려중" → 둘 다 내 후보 영웅
    ...

focus_heroes라는 새 필드도 추가됐다.

“솔저는 어떻게 플레이해?”처럼 영웅을 주제로 묻기만 하는 질문의 영웅은 current_hero가 아니라 focus_heroes로 처리한다.

이 구분이 없으면 “나는 모이라인데 솔저는 어떻게 플레이해?”에서 솔저가 current_hero로 잘못 잡힌다.



역할 선택 텍스트 인식 개선

“탱커”, “딜러”, “힐러”처럼 단독으로 역할만 답하는 경우를 더 정확하게 인식하도록 개선했다.

1
2
3
4
5
6
7
# 이전: 단어 포함 여부만 체크
# 현재: 문장 전체가 역할 단어와 일치할 때만 자기 역할 선언으로 인정
stripped = re.sub(r"[\s,.!?~]+", "", message)
short_role_replies = {
    "tank": {"탱커", "탱커요", "탱커임", "탱커에요", "탱커입니다"},
    ...
}

“탱커 OO 견제 하려고”처럼 문장 중간에 역할 단어가 섞인 경우는 자기 역할 선언으로 잘못 인식하지 않는다.






3. views.py 변경

음성 입력(STT) 제거, 이미지 업로드로 교체

기존에 있던 chat_stt 엔드포인트를 제거하고 chat_scoreboard_ocr 엔드포인트로 교체했다.

1
2
3
4
5
6
7
@require_http_methods(["POST"])
def chat_scoreboard_ocr(request):
    image_file = request.FILES.get("image")
    image_bytes = image_file.read()
    result = analyze_scoreboard_image(image_bytes, mime_type=mime_type, turn_id=turn_id)
    ...
    return JsonResponse({"report": result["report"], "turn_id": turn_id})

turn_id를 분석 전에 미리 만들어서 관리자 페이지에서 로그와 디버그 이미지를 같은 turn_id로 연결할 수 있게 했다.



CSRF 403 오류 수정

이미지 업로드 시 간헐적으로 CSRF 403이 발생하던 문제가 있었다.

FormData로 파일을 보낼 때 CSRF 토큰이 제대로 전달되지 않는 경우였다.

csrf_failure_debug 뷰를 추가해서 403 발생 시 쿠키/헤더 상태를 진단 로그로 남기게 했다.

1
2
3
4
5
def csrf_failure_debug(request, reason=""):
    logger.warning(
        "[CSRF DIAG] csrf_cookie_present=%s x_csrftoken_header_present=%s ...",
        csrf_cookie is not None, csrf_header is not None, ...
    )

settings.CSRF_FAILURE_VIEW에 이 뷰를 등록해서 403이 나면 원인을 바로 파악할 수 있게 했다.






4. Admin 페이지 개선

채팅 로그 선택/세션 전체 삭제

선택한 로그를 일괄 삭제하는 기본 액션 외에

선택한 로그가 속한 세션 전체(같은 log_session_id의 모든 로그)를 통째로 삭제하는 액션을 추가했다.

1
2
3
4
5
@admin.action(description="선택한 로그가 속한 세션 전체 삭제")
def delete_entire_session(self, request, queryset):
    session_ids = list(queryset.values_list("log_session_id", flat=True).distinct())
    session_logs = ChatLog.objects.filter(log_session_id__in=session_ids)
    deleted_count, _ = session_logs.delete()

스탯창 분석 로그를 삭제할 때는 logs/scoreboard_debug/{turn_id}/ 폴더(디버그 이미지)도 함께 삭제했다.



불만족 답변 메모 및 처리 완료 관리

ChatLogis_resolved(처리 완료)와 resolution_note(처리 메모) 필드를 추가했다.

1
2
is_resolved = models.BooleanField("처리 완료", default=False, db_index=True)
resolution_note = models.TextField("처리 메모", null=True, blank=True)

불만족 답변을 검토하고 나서 처리 완료 체크만 할 수도 있고, 메모만 남기고 나중에 다시 고칠 수도 있도록 두 필드를 분리했다.

UnsatisfiedChatLogAdmin에서만 이 두 필드를 편집할 수 있다.



컬럼 헤더 드롭다운 필터

테이블 헤더에 작은 필터 버튼을 추가했다. 클릭하면 그 컬럼 값으로 필터링하는 드롭다운이 나온다.

Django Admin 기본 사이드바 필터 링크를 JS(column_filter.js)가 재사용해서 팝업 형태로 보여주는 방식이다.

Django CSS 변수를 그대로 써서 라이트/다크 테마가 바뀌어도 자동으로 맞춰진다.



스탯창 진단 정보 Admin에서 확인

스탯창 분석 로그 상세 화면에서 다음 정보를 확인할 수 있다.

팀 패널 좌표(파란/빨간팀 각각의 위치), 전체화면 캡처 대응 coarse crop 사용 여부,

행별 row crop과 hero crop 썸네일 이미지, 영웅 인식 결과(확신도, TOP3 후보, 사용한 템플릿), 수치 인식 실패 항목 목록이다.

이 정보는 모두 관리자 전용 화면에만 표시되고 사용자 응답에는 포함되지 않는다.






마무리

이번 업데이트 변경 내용을 표로 정리하면 다음과 같다.

항목내용
스탯창 분석TAB 스탯창 이미지 → OpenCV 팀/영웅 인식 + Gemini 수치/피드백
전체화면 캡처 대응2단계 검출(coarse → fine)로 스탯창 영역 자동 추출
영웅 인식행 순서 고정(탱커/딜러/딜러/힐러/힐러) + 역할별 후보 제한
세션 연동스탯창 결과를 이후 채팅에서도 이어받음
팀 조합 평가composition 질문은 역할 몰라도 바로 답변
후속 질문생략형 질문만 이전 맥락 이어받음
아군 인식조합 불만/시너지/비교 문장에서 아군 영웅 정확히 구분
STT 제거음성 입력 대신 이미지 업로드로 교체
CSRF 수정403 발생 시 진단 로그 추가, 원인 파악 가능
Admin 삭제개별 삭제/세션 전체 삭제 + 디버그 이미지 동시 정리
Admin 불만족처리 완료 체크 + 메모 기능 추가
Admin 필터컬럼 헤더 드롭다운 필터
Admin 스탯창진단 이미지/좌표/유사도 점수 확인 가능
This post is licensed under CC BY 4.0 by the author.