Files
2025-11-29 18:18:51 +08:00

263 lines
7.8 KiB
Python

"""
Fuzzy Bone Matching Utilities
본 이름 유사도 기반 자동 매칭 알고리즘
"""
import re
from difflib import SequenceMatcher
from typing import Any, Dict, List, Optional, Tuple, Union
def normalize_bone_name(name: str) -> str:
"""
본 이름 정규화
- 소문자 변환
- 특수문자를 언더스코어로 변환
- 연속된 언더스코어 제거
- 양쪽 공백 제거
Examples:
"Left_Arm" -> "left_arm"
"left-arm" -> "left_arm"
"LeftArm" -> "leftarm"
"Left Arm" -> "left_arm"
"""
# 소문자 변환
normalized = name.lower()
# 특수문자를 언더스코어로 변환 (알파벳, 숫자, 점만 유지)
normalized = re.sub(r'[^a-z0-9.]', '_', normalized)
# 연속된 언더스코어를 하나로
normalized = re.sub(r'_+', '_', normalized)
# 양쪽 언더스코어 제거
normalized = normalized.strip('_')
return normalized
def calculate_similarity(name1: str, name2: str) -> float:
"""
두 본 이름 간 유사도 계산 (0.0 ~ 1.0)
알고리즘:
1. 정규화된 이름으로 SequenceMatcher 사용 (기본 점수)
2. 부분 문자열 매칭 보너스
3. 접두사/접미사 매칭 보너스
4. 단어 포함 보너스
Args:
name1: 첫 번째 본 이름
name2: 두 번째 본 이름
Returns:
유사도 점수 (0.0 = 전혀 다름, 1.0 = 완전 일치)
"""
# 정규화
norm1 = normalize_bone_name(name1)
norm2 = normalize_bone_name(name2)
# 완전 일치
if norm1 == norm2:
return 1.0
# SequenceMatcher로 기본 유사도 계산
base_score = SequenceMatcher(None, norm1, norm2).ratio()
# 보너스 점수 계산
bonus = 0.0
# 1. 부분 문자열 매칭 보너스 (한쪽이 다른 쪽에 포함)
if norm1 in norm2 or norm2 in norm1:
bonus += 0.15
# 2. 접두사 매칭 보너스 (left, right 등)
common_prefixes = ['left', 'right', 'up', 'low', 'upper', 'lower']
for prefix in common_prefixes:
if norm1.startswith(prefix) and norm2.startswith(prefix):
bonus += 0.1
break
# 3. 접미사 매칭 보너스 (l, r 등)
underscore_l_match = norm1.endswith('_l') and norm2.endswith('_l')
underscore_r_match = norm1.endswith('_r') and norm2.endswith('_r')
dot_l_match = norm1.endswith('.l') and norm2.endswith('.l')
dot_r_match = norm1.endswith('.r') and norm2.endswith('.r')
if underscore_l_match or underscore_r_match or dot_l_match or dot_r_match:
bonus += 0.1
# 4. 숫자 매칭 보너스 (Spine1, Spine2 등)
digits1 = re.findall(r'\d+', norm1)
digits2 = re.findall(r'\d+', norm2)
if digits1 and digits2 and digits1 == digits2:
bonus += 0.1
# 5. 단어 포함 보너스 (arm, hand, leg, foot 등)
keywords = ['arm', 'hand', 'leg', 'foot', 'finger', 'thumb', 'index',
'middle', 'ring', 'pinky', 'shoulder', 'elbow', 'wrist',
'hip', 'knee', 'ankle', 'toe', 'spine', 'neck', 'head']
for keyword in keywords:
if keyword in norm1 and keyword in norm2:
bonus += 0.05
break
# 최종 점수 (최대 1.0)
final_score = min(base_score + bonus, 1.0)
return final_score
def find_best_match(
source_bone: str,
target_bones: List[str],
threshold: float = 0.6,
return_score: bool = False
) -> Union[Optional[str], Tuple[Optional[str], float]]:
"""
타겟 본 리스트에서 가장 유사한 본 찾기
Args:
source_bone: 매칭할 소스 본 이름
target_bones: 타겟 본 이름 리스트
threshold: 최소 유사도 임계값 (0.0 ~ 1.0)
return_score: True면 (본_이름, 점수) 튜플 반환
Returns:
가장 유사한 타겟 본 이름, 또는 None (임계값 미만)
return_score=True면 (본_이름, 점수) 튜플
"""
best_match = None
best_score = 0.0
for target_bone in target_bones:
score = calculate_similarity(source_bone, target_bone)
if score > best_score and score >= threshold:
best_score = score
best_match = target_bone
if return_score:
return (best_match, best_score)
return best_match
def fuzzy_match_bones(
source_bones: List[str],
target_bones: List[str],
known_aliases: Dict[str, List[str]] = None,
threshold: float = 0.6,
prefer_exact: bool = True
) -> Dict[str, str]:
"""
Fuzzy matching을 사용한 전체 본 매핑
알고리즘:
1. 정확한 매칭 우선 (known_aliases 사용)
2. Fuzzy matching으로 나머지 매칭
3. 임계값 이상인 것만 포함
Args:
source_bones: 소스 본 이름 리스트
target_bones: 타겟 본 이름 리스트
known_aliases: 알려진 별칭 딕셔너리 {source: [target_alias1, ...]}
threshold: 최소 유사도 임계값
prefer_exact: True면 정확한 매칭 우선
Returns:
본 매핑 딕셔너리 {source_bone: target_bone}
"""
bone_map = {}
target_bone_names_lower = [b.lower() for b in target_bones]
matched_targets = set() # 중복 매칭 방지
# 1단계: 정확한 매칭 (known_aliases 사용)
if prefer_exact and known_aliases:
for source_bone in source_bones:
if source_bone not in known_aliases:
continue
# 별칭 리스트에서 타겟에 있는 것 찾기
for alias in known_aliases[source_bone]:
alias_lower = alias.lower()
if alias_lower in target_bone_names_lower:
idx = target_bone_names_lower.index(alias_lower)
actual_name = target_bones[idx]
# 이미 매칭된 타겟이 아니면 추가
if actual_name not in matched_targets:
bone_map[source_bone] = actual_name
matched_targets.add(actual_name)
break
# 2단계: Fuzzy matching으로 나머지 매칭
for source_bone in source_bones:
# 이미 매칭된 소스 본은 건너뛰기
if source_bone in bone_map:
continue
# 아직 매칭되지 않은 타겟 본들만 대상으로
available_targets = [t for t in target_bones if t not in matched_targets]
if not available_targets:
continue
# 가장 유사한 타겟 찾기
best_match, _ = find_best_match(
source_bone,
available_targets,
threshold=threshold,
return_score=True
)
if best_match:
bone_map[source_bone] = best_match
matched_targets.add(best_match)
return bone_map
def get_match_quality_report(bone_map: Dict[str, str]) -> Dict[str, Any]:
"""
본 매핑 품질 보고서 생성
Args:
bone_map: 본 매핑 딕셔너리
Returns:
품질 보고서 딕셔너리
"""
if not bone_map:
return {
'total_mappings': 0,
'quality': 'none',
'summary': 'No bone mappings found'
}
total = len(bone_map)
# 주요 본 체크 (최소한 있어야 하는 본들)
critical_bones = ['Hips', 'Spine', 'Head', 'LeftArm', 'RightArm',
'LeftLeg', 'RightLeg', 'LeftHand', 'RightHand']
critical_mapped = sum(1 for bone in critical_bones if bone in bone_map)
# 품질 평가
if critical_mapped >= 8:
quality = 'excellent'
elif critical_mapped >= 6:
quality = 'good'
elif critical_mapped >= 4:
quality = 'fair'
else:
quality = 'poor'
return {
'total_mappings': total,
'critical_bones_mapped': f'{critical_mapped}/{len(critical_bones)}',
'quality': quality,
'summary': f'{total} bones mapped, {critical_mapped}/{len(critical_bones)} critical bones'
}