""" Object Mapper — Synonym normalization for bridging YOLO labels and natural-language user commands. YOLO may detect "cup" while the user says "mug". This module provides a mapping layer and fuzzy-match helper to reconcile the two vocabularies. """ import logging from difflib import SequenceMatcher logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Synonym Map: user-language → canonical YOLO label # --------------------------------------------------------------------------- SYNONYM_MAP: dict[str, str] = { # --------------------------------------------------------------- # COCO has 80 classes. This map bridges common user vocabulary # to the actual YOLO/COCO label strings. # --------------------------------------------------------------- # person "human": "person", "worker": "person", "operator": "person", "man": "person", "woman": "person", "people": "person", "employee": "person", "technician": "person", # bicycle "bike": "bicycle", "cycle": "bicycle", # car "automobile": "car", "vehicle": "car", "sedan": "car", # motorcycle "motorbike": "motorcycle", "scooter": "motorcycle", # truck "van": "truck", "lorry": "truck", "pickup": "truck", # boat "ship": "boat", "vessel": "boat", "canoe": "boat", # traffic light "signal": "traffic light", "stoplight": "traffic light", # backpack "bag": "backpack", "rucksack": "backpack", "knapsack": "backpack", # umbrella "parasol": "umbrella", # handbag "purse": "handbag", "clutch": "handbag", # suitcase (toolboxes, cases look like suitcases to YOLO) "briefcase": "suitcase", "toolbox": "suitcase", "case": "suitcase", "luggage": "suitcase", "tool box": "suitcase", # bottle "water bottle": "bottle", "flask": "bottle", "jug": "bottle", "canteen": "bottle", "container": "bottle", # wine glass "goblet": "wine glass", "chalice": "wine glass", "glass": "wine glass", # cup "mug": "cup", "teacup": "cup", "coffee cup": "cup", "tumbler": "cup", # fork "prong": "fork", # knife (wrenches, tools often detected as knife-like) "blade": "knife", "wrench": "knife", "spanner": "knife", "screwdriver": "knife", "tool": "knife", # spoon "ladle": "spoon", "scoop": "spoon", # bowl "tray": "bowl", "dish": "bowl", "plate": "bowl", "bin": "bowl", "basket": "bowl", # banana / apple / orange (fruits) "fruit": "apple", # sandwich "sub": "sandwich", "burger": "sandwich", "wrap": "sandwich", # pizza "pie": "pizza", # cake "pastry": "cake", "dessert": "cake", # chair "seat": "chair", "stool": "chair", "office chair": "chair", # couch "sofa": "couch", "loveseat": "couch", "settee": "couch", # bed "mattress": "bed", "cot": "bed", # dining table "desk": "dining table", "table": "dining table", "workbench": "dining table", "bench": "dining table", "counter": "dining table", # tv "monitor": "tv", "screen": "tv", "display": "tv", "television": "tv", # laptop "notebook": "laptop", "computer": "laptop", "macbook": "laptop", # mouse "trackpad": "mouse", # remote "controller": "remote", "remote control": "remote", # keyboard "keypad": "keyboard", # cell phone "mobile": "cell phone", "phone": "cell phone", "smartphone": "cell phone", "iphone": "cell phone", "handset": "cell phone", "scanner": "cell phone", # microwave "microwave oven": "microwave", # oven "stove": "oven", "furnace": "oven", # refrigerator "fridge": "refrigerator", "cooler": "refrigerator", "freezer": "refrigerator", # book "notebook paper": "book", "manual": "book", "textbook": "book", "binder": "book", # clock "watch": "clock", "timer": "clock", # vase "pot": "vase", "planter": "vase", "jar": "vase", # scissors "cutter": "scissors", "shears": "scissors", "clipper": "scissors", "pliers": "scissors", "wire cutter": "scissors", # teddy bear "stuffed animal": "teddy bear", "plush": "teddy bear", "toy": "teddy bear", # potted plant "plant": "potted plant", "houseplant": "potted plant", "flower": "potted plant", # ----------------------------------------------------------- # Domain-specific aliases for industrial / demo contexts # ----------------------------------------------------------- "damaged part": "knife", "defective part": "knife", "component": "knife", "metal part": "knife", "part": "knife", "piece": "knife", "red bin": "bowl", "blue bin": "bowl", "sorting bin": "bowl", "reject bin": "bowl", "accept bin": "bowl", "circuit board": "keyboard", "pcb": "keyboard", "board": "keyboard", "hard hat": "sports ball", "helmet": "sports ball", "safety hat": "sports ball", "gloves": "backpack", "safety gloves": "backpack", "pallet": "suitcase", "crate": "suitcase", "carton": "suitcase", "box": "suitcase", "hand truck": "bicycle", "dolly": "bicycle", "cart": "bicycle", "forklift": "truck", "clipboard": "book", "blueprint": "book", "stethoscope": "tie", "syringe": "toothbrush", "safety goggles": "wine glass", "goggles": "wine glass", "oil can": "bottle", "spray can": "bottle", "battery": "suitcase", "car battery": "suitcase", "multimeter": "cell phone", "meter": "cell phone", "soldering iron": "knife", "magnifying lamp": "clock", } def normalize_label(label: str) -> str: """ Normalize a label using the synonym map. Args: label: Raw label string (from user command or YOLO). Returns: The canonical label if a synonym exists, otherwise the original label lowercased and stripped. """ cleaned = label.strip().lower() return SYNONYM_MAP.get(cleaned, cleaned) def _similarity(a: str, b: str) -> float: """Return a 0-1 similarity score between two strings.""" return SequenceMatcher(None, a.lower(), b.lower()).ratio() def find_best_match( query: str, detected_labels: list[str], threshold: float = 0.4, ) -> str | None: """ Find the best matching detected label for a user query term. Strategy: 1. Exact match after normalization. 2. Substring containment check. 3. Fuzzy similarity above *threshold*. Args: query: The term from the user command (e.g. "mug"). detected_labels: Labels returned by the vision detector. threshold: Minimum similarity score for fuzzy matching. Returns: The best matching label, or ``None`` if no match is found. """ normalized_query = normalize_label(query) normalized_detected = {normalize_label(lbl): lbl for lbl in detected_labels} # 1. Exact match on normalized labels if normalized_query in normalized_detected: logger.info( "Exact match: '%s' → '%s'", query, normalized_detected[normalized_query], ) return normalized_detected[normalized_query] # 2. Substring containment for norm_lbl, orig_lbl in normalized_detected.items(): if normalized_query in norm_lbl or norm_lbl in normalized_query: logger.info( "Substring match: '%s' → '%s'", query, orig_lbl ) return orig_lbl # 3. Fuzzy matching best_score = 0.0 best_label: str | None = None for norm_lbl, orig_lbl in normalized_detected.items(): score = _similarity(normalized_query, norm_lbl) if score > best_score: best_score = score best_label = orig_lbl if best_label and best_score >= threshold: logger.info( "Fuzzy match: '%s' → '%s' (score=%.2f)", query, best_label, best_score, ) return best_label logger.warning("No match found for query '%s'", query) return None