""" 3D mAP (mean Average Precision) 계산 모듈 3D 모델과 참조 모델 간의 3D 객체 감지 정확도를 평가합니다. """ import numpy as np from typing import List, Dict, Tuple class Map3DCalculator: """3D mAP 계산을 담당하는 클래스""" def __init__(self, iou_3d_thresholds: List[float] = [0.05, 0.1, 0.2]): """ 3D mAP 계산기 초기화 Args: iou_3d_thresholds (List[float]): 3D IoU 임계값 리스트 (더 관대한 임계값 사용) """ self.iou_3d_thresholds = iou_3d_thresholds self.default_iou_threshold = 0.05 # 기본 IoU threshold를 더 낮춤 def calculate_3d_map(self, model_3d: Dict, ground_truth_3d: Dict) -> float: """ 3D mAP를 계산합니다. Args: model_3d (Dict): 변환된 3D 모델 정보 ground_truth_3d (Dict): 참조 3D 모델 정보 Returns: float: 3D mAP 점수 """ try: # 3D 바운딩 박스 추출 pred_boxes_3d = self.extract_3d_bounding_boxes(model_3d) gt_boxes_3d = self.extract_3d_bounding_boxes(ground_truth_3d) if not pred_boxes_3d or not gt_boxes_3d: return 0.0 # 각 IoU 임계값에 대해 mAP 계산 map_scores = [] for iou_threshold in self.iou_3d_thresholds: # 3D IoU 기반 매칭 matched_pairs = self._match_3d_boxes(pred_boxes_3d, gt_boxes_3d, iou_threshold) # 정밀도-재현율 데이터 준비 predictions, ground_truths = self._prepare_3d_pr_data(matched_pairs, pred_boxes_3d, gt_boxes_3d) # Average Precision 계산 if predictions and ground_truths: ap_score = self._calculate_3d_average_precision(predictions, ground_truths) map_scores.append(ap_score) else: map_scores.append(0.0) # 평균 mAP 계산 mean_map = np.mean(map_scores) if map_scores else 0.0 return mean_map except Exception as e: print(f"Error in 3D mAP calculation: {str(e)}") return 0.0 def extract_3d_bounding_boxes(self, mesh: Dict) -> List[Dict]: """ 3D 메시에서 바운딩 박스를 추출합니다. 개별 객체별로 3D 바운딩 박스를 생성하여 더 정확한 3D mAP 계산을 수행합니다. Args: mesh (Dict): 3D 메시 정보 Returns: List[Dict]: 3D 바운딩 박스 리스트 """ print(f"3D 바운딩 박스 추출 시작") if 'vertices' not in mesh: print(" vertices 키가 없음, 빈 리스트 반환") return [] vertices = np.array(mesh['vertices']) if len(vertices) == 0: print(" vertices가 비어있음, 빈 리스트 반환") return [] print(f" vertices 개수: {len(vertices)}") # 개별 객체별 바운딩 박스 생성 bboxes_3d = [] # 방법 1: 클러스터링 기반 객체 분리 clustered_boxes = self._extract_clustered_3d_boxes(vertices) bboxes_3d.extend(clustered_boxes) print(f" 클러스터링 방법: {len(clustered_boxes)}개 박스") # 방법 2: 기하학적 특성 기반 분리 geometric_boxes = self._extract_geometric_3d_boxes(vertices, mesh) bboxes_3d.extend(geometric_boxes) print(f" 기하학적 방법: {len(geometric_boxes)}개 박스") # 방법 3: 전체 메시를 하나의 객체로 처리 (fallback) if not bboxes_3d: print(" 객체 분리 실패, 전체 메시를 하나의 객체로 처리") overall_box = self._extract_overall_3d_box(vertices) bboxes_3d.append(overall_box) # 중복 제거 및 필터링 (더 관대한 임계값 사용) filtered_boxes = self._filter_3d_boxes(bboxes_3d, iou_threshold=0.05) print(f" 필터링 후: {len(filtered_boxes)}개 박스") # 여전히 박스가 없는 경우 기본 박스 생성 if not filtered_boxes: print(" 모든 방법 실패, 기본 3D 박스 생성") filtered_boxes = self._create_default_3d_boxes(vertices) # 추가: 스케일 정규화를 통한 매칭 개선 filtered_boxes = self._normalize_boxes_for_matching(filtered_boxes, vertices) print(f" 최종 3D 바운딩 박스: {len(filtered_boxes)}개") return filtered_boxes def _create_default_3d_boxes(self, vertices: np.ndarray) -> List[Dict]: """기본 3D 박스 생성 (모든 방법 실패 시)""" print(" 기본 3D 박스 생성") if len(vertices) == 0: # vertices가 없는 경우 기본 정육면체 생성 return [{ 'center': [0.0, 0.0, 0.0], 'size': [1.0, 1.0, 1.0], 'volume': 1.0, 'confidence': 0.1, 'method': 'default_cube' }] # vertices가 있는 경우 바운딩 박스 계산 min_coords = np.min(vertices, axis=0) max_coords = np.max(vertices, axis=0) center = (min_coords + max_coords) / 2 size = max_coords - min_coords # 최소 크기 보장 size = np.maximum(size, [0.1, 0.1, 0.1]) volume = np.prod(size) boxes = [] # 방법 1: 전체 바운딩 박스 boxes.append({ 'center': center.tolist(), 'size': size.tolist(), 'volume': float(volume), 'confidence': 0.5, 'method': 'overall_bbox' }) # 방법 2: vertices를 2개 그룹으로 분할 if len(vertices) > 10: mid_point = len(vertices) // 2 group1 = vertices[:mid_point] group2 = vertices[mid_point:] for i, group in enumerate([group1, group2]): if len(group) > 0: min_coords = np.min(group, axis=0) max_coords = np.max(group, axis=0) center = (min_coords + max_coords) / 2 size = max_coords - min_coords size = np.maximum(size, [0.1, 0.1, 0.1]) volume = np.prod(size) boxes.append({ 'center': center.tolist(), 'size': size.tolist(), 'volume': float(volume), 'confidence': 0.3, 'method': f'split_group_{i+1}' }) print(f" 기본 3D 박스 생성 완료: {len(boxes)}개") return boxes def _normalize_boxes_for_matching(self, boxes: List[Dict], vertices: np.ndarray) -> List[Dict]: """매칭을 위한 박스 정규화""" if not boxes or len(vertices) == 0: return boxes # 전체 메시의 바운딩 박스 계산 mesh_min = np.min(vertices, axis=0) mesh_max = np.max(vertices, axis=0) mesh_size = mesh_max - mesh_min mesh_center = (mesh_min + mesh_max) / 2 normalized_boxes = [] for box in boxes: # 상대적 위치와 크기로 정규화 normalized_box = box.copy() # 중심점을 상대 좌표로 변환 if 'center' in box: relative_center = (np.array(box['center']) - mesh_center) / (mesh_size + 1e-8) normalized_box['normalized_center'] = relative_center.tolist() # 크기를 상대 크기로 변환 if 'size' in box: relative_size = np.array(box['size']) / (mesh_size + 1e-8) normalized_box['normalized_size'] = relative_size.tolist() # 매칭을 위한 추가 정보 normalized_box['mesh_center'] = mesh_center.tolist() normalized_box['mesh_size'] = mesh_size.tolist() normalized_boxes.append(normalized_box) return normalized_boxes def _extract_clustered_3d_boxes(self, vertices: np.ndarray) -> List[Dict]: """클러스터링을 사용한 3D 바운딩 박스 추출.""" try: from sklearn.cluster import KMeans # 정점 수가 충분한 경우에만 클러스터링 수행 if len(vertices) < 10: return [] # 클러스터 개수 결정 (정점 수에 비례) n_clusters = min(max(2, len(vertices) // 50), 5) # K-means 클러스터링 kmeans = KMeans(n_clusters=n_clusters, random_state=42, n_init=10) cluster_labels = kmeans.fit_predict(vertices) bboxes = [] for cluster_id in range(n_clusters): cluster_vertices = vertices[cluster_labels == cluster_id] if len(cluster_vertices) < 3: # 최소 정점 수 확인 continue # 클러스터별 바운딩 박스 계산 min_coords = np.min(cluster_vertices, axis=0) max_coords = np.max(cluster_vertices, axis=0) center = (min_coords + max_coords) / 2 size = max_coords - min_coords # 유효한 크기인지 확인 if np.all(size > 0.01): # 최소 크기 임계값 bbox = { 'center': center, 'size': size, 'min_coords': min_coords, 'max_coords': max_coords, 'volume': np.prod(size), 'confidence': 0.8, 'method': 'clustering', 'cluster_id': cluster_id, 'num_vertices': len(cluster_vertices) } bboxes.append(bbox) return bboxes except ImportError: # sklearn이 없는 경우 빈 리스트 반환 return [] except Exception as e: print(f"클러스터링 중 오류 발생: {e}") return [] def _extract_geometric_3d_boxes(self, vertices: np.ndarray, mesh: Dict) -> List[Dict]: """기하학적 특성 기반 3D 바운딩 박스 추출.""" bboxes = [] # 방법 1: Z축 기반 분리 (높이별 객체 분리) z_based_boxes = self._extract_z_based_boxes(vertices) bboxes.extend(z_based_boxes) # 방법 2: 거리 기반 분리 distance_based_boxes = self._extract_distance_based_boxes(vertices) bboxes.extend(distance_based_boxes) # 방법 3: 밀도 기반 분리 density_based_boxes = self._extract_density_based_boxes(vertices) bboxes.extend(density_based_boxes) return bboxes def _extract_z_based_boxes(self, vertices: np.ndarray) -> List[Dict]: """Z축(높이) 기반으로 객체를 분리하여 바운딩 박스 생성.""" if len(vertices) < 10: return [] # Z축 값으로 정렬 z_values = vertices[:, 2] z_sorted_indices = np.argsort(z_values) # Z축을 여러 구간으로 나누기 n_segments = min(3, len(vertices) // 20) # 최대 3개 구간 if n_segments < 2: return [] segment_size = len(vertices) // n_segments bboxes = [] for i in range(n_segments): start_idx = i * segment_size end_idx = (i + 1) * segment_size if i < n_segments - 1 else len(vertices) segment_vertices = vertices[z_sorted_indices[start_idx:end_idx]] if len(segment_vertices) < 5: continue # 구간별 바운딩 박스 계산 min_coords = np.min(segment_vertices, axis=0) max_coords = np.max(segment_vertices, axis=0) center = (min_coords + max_coords) / 2 size = max_coords - min_coords if np.all(size > 0.01): bbox = { 'center': center, 'size': size, 'min_coords': min_coords, 'max_coords': max_coords, 'volume': np.prod(size), 'confidence': 0.7, 'method': 'z_based', 'segment_id': i, 'num_vertices': len(segment_vertices) } bboxes.append(bbox) return bboxes def _extract_distance_based_boxes(self, vertices: np.ndarray) -> List[Dict]: """거리 기반으로 객체를 분리하여 바운딩 박스 생성.""" if len(vertices) < 20: return [] # 중심점에서의 거리 계산 center = np.mean(vertices, axis=0) distances = np.linalg.norm(vertices - center, axis=1) # 거리 기준으로 정점들을 그룹화 distance_threshold = np.percentile(distances, 60) # 60% 분위수 사용 # 중심에 가까운 정점들 close_vertices = vertices[distances <= distance_threshold] # 중심에서 먼 정점들 far_vertices = vertices[distances > distance_threshold] bboxes = [] # 가까운 정점들 그룹 if len(close_vertices) >= 5: min_coords = np.min(close_vertices, axis=0) max_coords = np.max(close_vertices, axis=0) center = (min_coords + max_coords) / 2 size = max_coords - min_coords if np.all(size > 0.01): bbox = { 'center': center, 'size': size, 'min_coords': min_coords, 'max_coords': max_coords, 'volume': np.prod(size), 'confidence': 0.6, 'method': 'distance_based', 'group': 'close', 'num_vertices': len(close_vertices) } bboxes.append(bbox) # 먼 정점들 그룹 if len(far_vertices) >= 5: min_coords = np.min(far_vertices, axis=0) max_coords = np.max(far_vertices, axis=0) center = (min_coords + max_coords) / 2 size = max_coords - min_coords if np.all(size > 0.01): bbox = { 'center': center, 'size': size, 'min_coords': min_coords, 'max_coords': max_coords, 'volume': np.prod(size), 'confidence': 0.6, 'method': 'distance_based', 'group': 'far', 'num_vertices': len(far_vertices) } bboxes.append(bbox) return bboxes def _extract_density_based_boxes(self, vertices: np.ndarray) -> List[Dict]: """밀도 기반으로 객체를 분리하여 바운딩 박스 생성.""" if len(vertices) < 30: return [] # 간단한 그리드 기반 밀도 계산 min_coords = np.min(vertices, axis=0) max_coords = np.max(vertices, axis=0) # 그리드 크기 결정 grid_size = (max_coords - min_coords) / 3 # 3x3x3 그리드 bboxes = [] for i in range(3): for j in range(3): for k in range(3): # 그리드 셀 경계 계산 cell_min = min_coords + np.array([i, j, k]) * grid_size cell_max = cell_min + grid_size # 해당 셀에 속하는 정점들 찾기 mask = np.all((vertices >= cell_min) & (vertices <= cell_max), axis=1) cell_vertices = vertices[mask] if len(cell_vertices) >= 5: # 충분한 정점이 있는 셀만 처리 center = (cell_min + cell_max) / 2 size = cell_max - cell_min bbox = { 'center': center, 'size': size, 'min_coords': cell_min, 'max_coords': cell_max, 'volume': np.prod(size), 'confidence': 0.5, 'method': 'density_based', 'grid_cell': (i, j, k), 'num_vertices': len(cell_vertices) } bboxes.append(bbox) return bboxes def _extract_overall_3d_box(self, vertices: np.ndarray) -> Dict: """전체 메시를 하나의 3D 바운딩 박스로 처리.""" min_coords = np.min(vertices, axis=0) max_coords = np.max(vertices, axis=0) center = (min_coords + max_coords) / 2 size = max_coords - min_coords return { 'center': center, 'size': size, 'min_coords': min_coords, 'max_coords': max_coords, 'volume': np.prod(size), 'confidence': 1.0, 'method': 'overall', 'num_vertices': len(vertices) } def _filter_3d_boxes(self, bboxes: List[Dict], iou_threshold: float = 0.1) -> List[Dict]: """3D 바운딩 박스 필터링 및 중복 제거.""" if not bboxes: return [] # 1. 최소 크기 필터링 filtered_boxes = [] for bbox in bboxes: if np.all(bbox['size'] > 0.01): # 최소 크기 임계값 filtered_boxes.append(bbox) if not filtered_boxes: return [] # 2. 3D IoU 기반 중복 제거 final_boxes = [] used_indices = set() # 신뢰도 순으로 정렬 sorted_boxes = sorted(filtered_boxes, key=lambda x: x['confidence'], reverse=True) for i, bbox1 in enumerate(sorted_boxes): if i in used_indices: continue is_duplicate = False for j, bbox2 in enumerate(sorted_boxes[i+1:], i+1): if j in used_indices: continue iou = self.compute_3d_iou(bbox1, bbox2) if iou > iou_threshold: # 더 관대한 3D IoU 임계값 사용 used_indices.add(j) is_duplicate = True if not is_duplicate: final_boxes.append(bbox1) used_indices.add(i) # 3. 상위 결과만 반환 (최대 5개) final_boxes.sort(key=lambda x: x['confidence'], reverse=True) return final_boxes[:5] def compute_3d_iou(self, box1: Dict, box2: Dict) -> float: """ 두 3D 바운딩 박스 간의 IoU를 계산합니다. 정규화된 좌표와 원본 좌표 모두 고려하여 더 정확한 매칭을 수행합니다. Args: box1 (Dict): 첫 번째 3D 박스 box2 (Dict): 두 번째 3D 박스 Returns: float: 3D IoU 값 (0-1) """ # 정규화된 좌표가 있는 경우 우선 사용 if 'normalized_center' in box1 and 'normalized_center' in box2: return self._compute_normalized_3d_iou(box1, box2) # 원본 좌표 사용 if 'min_coords' not in box1 or 'min_coords' not in box2: # min_coords가 없는 경우 center와 size로부터 계산 return self._compute_3d_iou_from_center_size(box1, box2) # 교집합 영역 계산 min1, max1 = np.array(box1['min_coords']), np.array(box1['max_coords']) min2, max2 = np.array(box2['min_coords']), np.array(box2['max_coords']) # 교집합의 최소/최대 좌표 intersection_min = np.maximum(min1, min2) intersection_max = np.minimum(max1, max2) # 교집합이 존재하는지 확인 if np.any(intersection_min >= intersection_max): return 0.0 # 교집합 부피 계산 intersection_volume = np.prod(intersection_max - intersection_min) # 각 박스의 부피 계산 volume1 = np.prod(max1 - min1) volume2 = np.prod(max2 - min2) # 합집합 부피 계산 union_volume = volume1 + volume2 - intersection_volume if union_volume == 0: return 0.0 return intersection_volume / union_volume def _compute_normalized_3d_iou(self, box1: Dict, box2: Dict) -> float: """정규화된 좌표를 사용한 3D IoU 계산""" try: # 정규화된 중심점과 크기 사용 center1 = np.array(box1['normalized_center']) size1 = np.array(box1['normalized_size']) center2 = np.array(box2['normalized_center']) size2 = np.array(box2['normalized_size']) # 바운딩 박스 경계 계산 min1 = center1 - size1 / 2 max1 = center1 + size1 / 2 min2 = center2 - size2 / 2 max2 = center2 + size2 / 2 # 교집합 계산 intersection_min = np.maximum(min1, min2) intersection_max = np.minimum(max1, max2) if np.any(intersection_min >= intersection_max): return 0.0 intersection_volume = np.prod(intersection_max - intersection_min) volume1 = np.prod(size1) volume2 = np.prod(size2) union_volume = volume1 + volume2 - intersection_volume return intersection_volume / union_volume if union_volume > 0 else 0.0 except Exception: return 0.0 def _compute_3d_iou_from_center_size(self, box1: Dict, box2: Dict) -> float: """center와 size로부터 3D IoU 계산""" try: center1 = np.array(box1['center']) size1 = np.array(box1['size']) center2 = np.array(box2['center']) size2 = np.array(box2['size']) # 바운딩 박스 경계 계산 min1 = center1 - size1 / 2 max1 = center1 + size1 / 2 min2 = center2 - size2 / 2 max2 = center2 + size2 / 2 # 교집합 계산 intersection_min = np.maximum(min1, min2) intersection_max = np.minimum(max1, max2) if np.any(intersection_min >= intersection_max): return 0.0 intersection_volume = np.prod(intersection_max - intersection_min) volume1 = np.prod(size1) volume2 = np.prod(size2) union_volume = volume1 + volume2 - intersection_volume return intersection_volume / union_volume if union_volume > 0 else 0.0 except Exception: return 0.0 def _match_3d_boxes(self, pred_boxes: List[Dict], gt_boxes: List[Dict], iou_threshold: float) -> List[Tuple[int, int, float]]: """ 3D 예측 박스와 Ground Truth 박스를 매칭합니다. 다중 매칭 전략을 사용하여 더 나은 매칭을 수행합니다. Args: pred_boxes (List[Dict]): 예측 3D 박스 리스트 gt_boxes (List[Dict]): Ground Truth 3D 박스 리스트 iou_threshold (float): 3D IoU 임계값 Returns: List[Tuple[int, int, float]]: 매칭된 박스 쌍 (pred_idx, gt_idx, iou) """ matches = [] used_gt = set() # 예측 박스를 신뢰도 순으로 정렬 sorted_pred = sorted(pred_boxes, key=lambda x: x['confidence'], reverse=True) # 1차: 정확한 IoU 매칭 for pred_idx, pred_box in enumerate(sorted_pred): best_iou = 0 best_gt_idx = -1 for gt_idx, gt_box in enumerate(gt_boxes): if gt_idx in used_gt: continue iou = self.compute_3d_iou(pred_box, gt_box) if iou > best_iou: best_iou = iou best_gt_idx = gt_idx # 매칭 임계값 확인 if best_gt_idx != -1 and best_iou >= iou_threshold: matches.append((pred_idx, best_gt_idx, best_iou)) used_gt.add(best_gt_idx) # 2차: 낮은 임계값으로 추가 매칭 시도 if not matches and iou_threshold > 0.01: print(f" 정확한 매칭 실패, 낮은 임계값으로 재시도: {iou_threshold * 0.5}") return self._match_3d_boxes(pred_boxes, gt_boxes, iou_threshold * 0.5) # 3차: 거리 기반 매칭 (IoU가 낮은 경우) if not matches: print(" IoU 매칭 실패, 거리 기반 매칭 시도") matches = self._match_by_distance(pred_boxes, gt_boxes) return matches def _match_by_distance(self, pred_boxes: List[Dict], gt_boxes: List[Dict]) -> List[Tuple[int, int, float]]: """거리 기반 매칭 (IoU 매칭 실패 시 사용)""" matches = [] used_gt = set() for pred_idx, pred_box in enumerate(pred_boxes): best_distance = float('inf') best_gt_idx = -1 for gt_idx, gt_box in enumerate(gt_boxes): if gt_idx in used_gt: continue # 중심점 간 거리 계산 if 'center' in pred_box and 'center' in gt_box: center1 = np.array(pred_box['center']) center2 = np.array(gt_box['center']) distance = np.linalg.norm(center1 - center2) if distance < best_distance: best_distance = distance best_gt_idx = gt_idx # 거리 임계값 확인 (상대적으로 관대한 임계값) if best_gt_idx != -1 and best_distance < 1.0: # 임계값을 1.0으로 설정 # 거리를 IoU 스타일 점수로 변환 (0-1 범위) similarity_score = max(0, 1 - best_distance / 2.0) matches.append((pred_idx, best_gt_idx, similarity_score)) used_gt.add(best_gt_idx) return matches def _prepare_3d_pr_data(self, matches: List[Tuple[int, int, float]], pred_boxes: List[Dict], gt_boxes: List[Dict]) -> Tuple[List, List]: """ 3D 정밀도-재현율 계산을 위한 데이터를 준비합니다. Args: matches (List[Tuple[int, int, float]]): 매칭된 박스 쌍 pred_boxes (List[Dict]): 예측 3D 박스 리스트 gt_boxes (List[Dict]): Ground Truth 3D 박스 리스트 Returns: Tuple[List, List]: (예측 결과, Ground Truth) """ predictions = [] ground_truths = [] # 매칭된 박스들 for pred_idx, gt_idx, iou in matches: predictions.append({ 'confidence': pred_boxes[pred_idx]['confidence'], 'is_positive': True }) ground_truths.append(True) # 매칭되지 않은 예측 박스들 (False Positive) matched_pred_indices = {match[0] for match in matches} for i, pred_box in enumerate(pred_boxes): if i not in matched_pred_indices: predictions.append({ 'confidence': pred_box['confidence'], 'is_positive': False }) ground_truths.append(False) return predictions, ground_truths def _calculate_3d_average_precision(self, predictions: List, ground_truths: List) -> float: """ 3D Average Precision을 계산합니다. Args: predictions (List): 예측 결과 리스트 ground_truths (List): Ground Truth 리스트 Returns: float: 3D Average Precision 점수 """ if not predictions or not ground_truths: return 0.0 # 신뢰도 순으로 정렬 sorted_data = sorted(zip(predictions, ground_truths), key=lambda x: x[0]['confidence'], reverse=True) # 정밀도-재현율 곡선 계산 precisions = [] recalls = [] tp = 0 fp = 0 total_positives = sum(ground_truths) for pred, gt in sorted_data: if gt: tp += 1 else: fp += 1 precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0 recall = tp / total_positives if total_positives > 0 else 0.0 precisions.append(precision) recalls.append(recall) # Average Precision 계산 (11-point interpolation) ap = 0.0 for t in np.arange(0, 1.1, 0.1): if np.sum(np.array(recalls) >= t) == 0: p = 0 else: p = np.max(np.array(precisions)[np.array(recalls) >= t]) ap += p / 11.0 return ap