Files
baseball-automation/core/field_calculator.py
2026-05-02 16:24:42 +09:00

256 lines
8.0 KiB
Python

"""
core/field_calculator.py — 타구 좌표/거리/수비 시퀀스 계산
필드 좌표 기반의 타구 처리 로직. Playwright 의존성 없음.
"""
from __future__ import annotations
import math
import re
from typing import Any
from core.config_loader import (
field_coordinates,
hit_ball_type_map,
foul_fly_coords,
position_number_map,
)
# ──────────────────────────────────────────────
# 타구 종류 추론
# ──────────────────────────────────────────────
def infer_hit_ball_type(result_text: str) -> str:
"""결과 텍스트에서 타구 종류 추론
'2루수 땅볼 아웃''땅볼'
'좌익수 뒤 2루타''일반바운드'
"""
if "번트" in result_text:
return "번트타구"
if "몸에 맞는 타구" in result_text:
return "땅볼"
if "파울희생플라이" in result_text or "파울 희생플라이" in result_text:
return "플라이"
if "파울플라이" in result_text:
return "플라이"
if "라인드라이브" in result_text or "직선타" in result_text:
return "라인드라이브"
if "플라이" in result_text:
return "플라이"
if "땅볼" in result_text:
return "땅볼"
if "홈런" in result_text:
return "홈런성타구"
return "일반바운드"
def get_hit_ball_type_code(hit_ball_type: str) -> str:
"""타구 종류 라벨 → 사이트 value 코드"""
return hit_ball_type_map().get(hit_ball_type, "1")
# ──────────────────────────────────────────────
# 필드 존 추론
# ──────────────────────────────────────────────
ORDERED_ZONES = (
"좌중간", "우중간",
"좌전", "중전", "우전",
"좌월", "중월", "우월",
"좌익수", "중견수", "우익수",
"유격수", "3루수", "2루수", "1루수",
"투수", "포수",
)
def infer_field_zone(result_text: str) -> str:
"""결과 텍스트에서 타구 방향(zone) 추론
'우익수 앞 1루타''우익수'
"""
if "몸에 맞는 타구" in result_text:
return "1루수"
for zone in ORDERED_ZONES:
if zone in result_text:
return zone
return "중견수"
def extract_direction_offsets(result_text: str) -> tuple[int, int]:
"""결과 텍스트에서 방향 오프셋 추출
'좌익수 왼쪽 뒤' → (-1, -1)
"""
x_delta = 0
y_delta = 0
if "왼쪽" in result_text:
x_delta -= 1
if "오른쪽" in result_text:
x_delta += 1
if "" in result_text:
y_delta += 1
if "" in result_text:
y_delta -= 1
return x_delta, y_delta
def is_infield_zone(zone: str) -> bool:
"""내야 존인지 확인"""
return zone in {"투수", "포수", "1루수", "2루수", "3루수", "유격수"}
# ──────────────────────────────────────────────
# 좌표 계산
# ──────────────────────────────────────────────
def get_zone_coordinates(zone: str) -> tuple[int, int]:
"""존 이름 → (x, y) 퍼센트 좌표"""
coords = field_coordinates()
coord = coords.get(zone, coords.get("중견수", [50, 24]))
return tuple(coord)
def get_foul_fly_coordinates(side: str) -> tuple[int, int]:
"""파울 플라이 좌표 ('left' 또는 'right')"""
coords = foul_fly_coords()
return tuple(coords.get(side, [50, 70]))
def calculate_hit_ball_coordinates(
result_text: str,
zone: str | None = None,
) -> tuple[int, int]:
"""결과 텍스트로부터 타구 좌표 계산
Returns: (x, y) 퍼센트 좌표
"""
if zone is None:
zone = infer_field_zone(result_text)
x, y = get_zone_coordinates(zone)
x_delta, y_delta = extract_direction_offsets(result_text)
infield = is_infield_zone(zone)
step = 3 if infield else 5
x += x_delta * step
y += y_delta * step
# 범위 제한
x = max(0, min(100, x))
y = max(0, min(100, y))
return x, y
def calculate_distance(x: int, y: int, meter_per_px: float) -> float:
"""좌표에서 홈까지의 거리 계산 (미터)"""
home_x, home_y = 50, 93
dx = (x - home_x) * meter_per_px
dy = (y - home_y) * meter_per_px
return math.sqrt(dx * dx + dy * dy)
# ──────────────────────────────────────────────
# 수비 시퀀스 추출
# ──────────────────────────────────────────────
def _position_label_map() -> dict[str, str]:
"""번호 → 포지션명 역매핑"""
return {v: k for k, v in position_number_map().items()}
def extract_defense_sequence(result_text: str) -> list[str]:
"""결과 텍스트에서 수비 시퀀스 추출
'2루수 땅볼 아웃 (2루수->1루수 송구아웃)' → ['2루수', '1루수']
"""
pos_label = _position_label_map()
# 1) '2-6', '2-5-3' 같은 숫자 패턴
num_seq_match = re.search(r"(\d+(?:-\d+)+)", result_text)
if num_seq_match:
nums = num_seq_match.group(1).split("-")
pos_names = [pos_label[n] for n in nums if n in pos_label]
if pos_names:
return pos_names
# 2) 괄호 안에서 포지션 추출
parenthetical_match = re.search(r"\(([^)]*)\)", result_text)
if parenthetical_match:
sequence = re.findall(
r"(투수|포수|1루수|2루수|3루수|유격수|좌익수|중견수|우익수)",
parenthetical_match.group(1),
)
if sequence:
return sequence
# 3) 괄호 앞 본문에서 포지션 추출
leading_text = result_text.split("(", 1)[0]
sequence = re.findall(
r"(투수|포수|1루수|2루수|3루수|유격수|좌익수|중견수|우익수)",
leading_text,
)
if sequence:
return sequence
# 4) 존에서 폴백
zone = infer_field_zone(result_text)
pos_num = position_number_map()
if zone in pos_num:
return [zone]
return []
def extract_error_position(result_text: str) -> str | None:
"""실책 관련 텍스트에서 실책 수비자 포지션 추출"""
parenthetical_match = re.search(r"\(([^)]*실책[^)]*)\)", result_text)
search_texts = [parenthetical_match.group(1)] if parenthetical_match else []
search_texts.append(result_text)
for text in search_texts:
positions = re.findall(
r"(투수|포수|1루수|2루수|3루수|유격수|좌익수|중견수|우익수)",
text,
)
if positions:
return positions[0]
return None
def infer_error_position_fallback(text: str) -> str:
"""실책 포지션 추론 폴백"""
if "야수선택" in text:
return "야수선택"
if "도루" in text:
return "포수"
if "포구" in text:
return "포수"
if "송구" in text:
return "투수"
return "포수"
def is_error_result(result_text: str) -> bool:
"""실책 결과인지 확인"""
return "실책" in result_text
def is_throwing_error(result_text: str) -> bool:
"""송구 실책인지 확인"""
keywords = ("송구실책", "송구 실책", "악송구", "throwing error", "송구에러")
return any(keyword in result_text for keyword in keywords)
def is_double_play_result(result_text: str) -> bool:
"""병살인지 확인"""
return "병살" in result_text
def build_double_play_first_sequence(event: dict[str, Any]) -> list[str]:
"""병살 이벤트의 첫 번째 수비 시퀀스"""
result_text = ((event.get("result") or {}).get("text") or "").strip()
return extract_defense_sequence(result_text)