| | |
| | """ |
| | Smart Bubble Placement System |
| | Uses image analysis to find optimal bubble positions without CAM data |
| | """ |
| |
|
| | import cv2 |
| | import numpy as np |
| | import math |
| | from typing import Tuple, List, Optional |
| | from backend.utils import get_panel_type, types |
| |
|
| | BUBBLE_WIDTH = 200 |
| | BUBBLE_HEIGHT = 94 |
| |
|
| | class SmartBubblePlacer: |
| | def __init__(self): |
| | """Initialize smart bubble placement system""" |
| | self.face_detector = None |
| | try: |
| | from backend.speech_bubble.modern_face_detection import ModernFaceDetector |
| | self.face_detector = ModernFaceDetector() |
| | except ImportError: |
| | print("Modern face detector not available, using basic placement") |
| | |
| | def analyze_image_content(self, image_path: str) -> dict: |
| | """ |
| | Analyze image content to find optimal bubble placement areas |
| | Returns: { |
| | 'face_regions': [(x, y, w, h), ...], |
| | 'empty_areas': [(x, y, w, h), ...], |
| | 'busy_areas': [(x, y, w, h), ...], |
| | 'edges': [(x, y), ...] |
| | } |
| | """ |
| | image = cv2.imread(image_path) |
| | if image is None: |
| | return {'face_regions': [], 'empty_areas': [], 'busy_areas': [], 'edges': []} |
| | |
| | height, width = image.shape[:2] |
| | gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) |
| | |
| | |
| | face_regions = self._detect_faces(image) |
| | |
| | |
| | empty_areas = self._find_empty_areas(gray) |
| | |
| | |
| | busy_areas = self._find_busy_areas(gray) |
| | |
| | |
| | edges = self._find_edge_positions(width, height) |
| | |
| | return { |
| | 'face_regions': face_regions, |
| | 'empty_areas': empty_areas, |
| | 'busy_areas': busy_areas, |
| | 'edges': edges |
| | } |
| | |
| | def _detect_faces(self, image) -> List[Tuple[int, int, int, int]]: |
| | """Detect face regions in image""" |
| | if self.face_detector: |
| | |
| | try: |
| | |
| | if isinstance(image, str): |
| | |
| | img = cv2.imread(image) |
| | else: |
| | |
| | img = image |
| | |
| | faces = self.face_detector.detect_faces_opencv(img) |
| | face_regions = [] |
| | for face in faces: |
| | if face != (-1, -1): |
| | |
| | x, y = face |
| | face_regions.append((x-50, y-50, 100, 100)) |
| | return face_regions |
| | except Exception as e: |
| | print(f"Face detection error: {e}") |
| | return [] |
| | else: |
| | |
| | try: |
| | if isinstance(image, str): |
| | img = cv2.imread(image) |
| | else: |
| | img = image |
| | |
| | gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) |
| | face_cascade = cv2.CascadeClassifier(cv2.data.haarcascades + 'haarcascade_frontalface_default.xml') |
| | faces = face_cascade.detectMultiScale(gray, 1.1, 4) |
| | return [(x, y, w, h) for (x, y, w, h) in faces] |
| | except Exception as e: |
| | print(f"Fallback face detection error: {e}") |
| | return [] |
| | |
| | def _find_empty_areas(self, gray_image) -> List[Tuple[int, int, int, int]]: |
| | """Find areas with low variance (good for bubbles)""" |
| | |
| | kernel = np.ones((20, 20), np.float32) / 400 |
| | mean = cv2.filter2D(gray_image.astype(np.float32), -1, kernel) |
| | mean_sq = cv2.filter2D((gray_image.astype(np.float32))**2, -1, kernel) |
| | variance = mean_sq - mean**2 |
| | |
| | |
| | threshold = np.percentile(variance, 20) |
| | low_var_mask = variance < threshold |
| | |
| | |
| | num_labels, labels = cv2.connectedComponents(low_var_mask.astype(np.uint8)) |
| | |
| | empty_areas = [] |
| | for label in range(1, num_labels): |
| | mask = labels == label |
| | if np.sum(mask) > 1000: |
| | y_coords, x_coords = np.where(mask) |
| | x_min, x_max = x_coords.min(), x_coords.max() |
| | y_min, y_max = y_coords.min(), y_coords.max() |
| | empty_areas.append((x_min, y_min, x_max-x_min, y_max-y_min)) |
| | |
| | return empty_areas |
| | |
| | def _find_busy_areas(self, gray_image) -> List[Tuple[int, int, int, int]]: |
| | """Find areas with high variance (avoid for bubbles)""" |
| | |
| | kernel = np.ones((20, 20), np.float32) / 400 |
| | mean = cv2.filter2D(gray_image.astype(np.float32), -1, kernel) |
| | mean_sq = cv2.filter2D((gray_image.astype(np.float32))**2, -1, kernel) |
| | variance = mean_sq - mean**2 |
| | |
| | |
| | threshold = np.percentile(variance, 80) |
| | high_var_mask = variance > threshold |
| | |
| | |
| | num_labels, labels = cv2.connectedComponents(high_var_mask.astype(np.uint8)) |
| | |
| | busy_areas = [] |
| | for label in range(1, num_labels): |
| | mask = labels == label |
| | if np.sum(mask) > 500: |
| | y_coords, x_coords = np.where(mask) |
| | x_min, x_max = x_coords.min(), x_coords.max() |
| | y_min, y_max = y_coords.min(), y_coords.max() |
| | busy_areas.append((x_min, y_min, x_max-x_min, y_max-y_min)) |
| | |
| | return busy_areas |
| | |
| | def _find_edge_positions(self, width: int, height: int) -> List[Tuple[int, int]]: |
| | """Find good edge positions for bubbles""" |
| | margin = 50 |
| | edge_positions = [] |
| | |
| | |
| | for x in range(margin, width - margin, 100): |
| | edge_positions.append((x, margin)) |
| | |
| | |
| | for y in range(margin, height - margin, 100): |
| | edge_positions.append((width - margin, y)) |
| | |
| | |
| | corner_margin = 80 |
| | for x in range(width - corner_margin - 100, width - corner_margin, 50): |
| | for y in range(margin, margin + 100, 50): |
| | edge_positions.append((x, y)) |
| | |
| | return edge_positions |
| | |
| | def get_optimal_bubble_position(self, image_path: str, panel_coords: Tuple[int, int, int, int], |
| | lip_coords: Optional[Tuple[int, int]] = None) -> Tuple[int, int]: |
| | """ |
| | Find optimal bubble position based on image analysis |
| | """ |
| | |
| | analysis = self.analyze_image_content(image_path) |
| | |
| | |
| | left, right, top, bottom = panel_coords |
| | panel_width = right - left |
| | panel_height = bottom - top |
| | |
| | |
| | candidates = [] |
| | |
| | |
| | for edge_x, edge_y in analysis['edges']: |
| | if (left <= edge_x <= right and top <= edge_y <= bottom): |
| | candidates.append((edge_x, edge_y, 100)) |
| | |
| | |
| | for x, y, w, h in analysis['empty_areas']: |
| | center_x = x + w // 2 |
| | center_y = y + h // 2 |
| | if (left <= center_x <= right and top <= center_y <= bottom): |
| | candidates.append((center_x, center_y, 80)) |
| | |
| | |
| | upper_y = top + panel_height * 0.2 |
| | for x in range(left + 50, right - 50, 100): |
| | candidates.append((x, upper_y, 70)) |
| | |
| | |
| | corner_margin = 40 |
| | corners = [ |
| | (left + corner_margin, top + corner_margin), |
| | (right - corner_margin, top + corner_margin), |
| | (left + corner_margin, top + panel_height * 0.3), |
| | (right - corner_margin, top + panel_height * 0.3) |
| | ] |
| | for x, y in corners: |
| | candidates.append((x, y, 60)) |
| | |
| | |
| | filtered_candidates = [] |
| | for x, y, score in candidates: |
| | |
| | overlaps_face = False |
| | for fx, fy, fw, fh in analysis['face_regions']: |
| | if (fx <= x <= fx + fw and fy <= y <= fy + fh): |
| | overlaps_face = True |
| | break |
| | |
| | |
| | overlaps_busy = False |
| | for bx, by, bw, bh in analysis['busy_areas']: |
| | if (bx <= x <= bx + bw and by <= y <= by + bh): |
| | overlaps_busy = True |
| | break |
| | |
| | |
| | too_close_to_lip = False |
| | if lip_coords and lip_coords != (-1, -1): |
| | lip_x, lip_y = lip_coords |
| | distance = math.sqrt((x - lip_x)**2 + (y - lip_y)**2) |
| | if distance < 80: |
| | too_close_to_lip = True |
| | |
| | if not overlaps_face and not overlaps_busy and not too_close_to_lip: |
| | filtered_candidates.append((x, y, score)) |
| | |
| | |
| | if filtered_candidates: |
| | |
| | filtered_candidates.sort(key=lambda x: x[2], reverse=True) |
| | best_x, best_y, _ = filtered_candidates[0] |
| | return (best_x, best_y) |
| | else: |
| | |
| | return (left + panel_width // 2, top + panel_height * 0.2) |
| |
|
| | def get_smart_bubble_position(image_path: str, panel_coords: Tuple[int, int, int, int], |
| | lip_coords: Optional[Tuple[int, int]] = None) -> Tuple[int, int]: |
| | """Main function to get smart bubble position""" |
| | placer = SmartBubblePlacer() |
| | return placer.get_optimal_bubble_position(image_path, panel_coords, lip_coords) |