diff --git a/core/common_classifier.py b/core/common_classifier.py index e434a52..529b82d 100644 --- a/core/common_classifier.py +++ b/core/common_classifier.py @@ -511,6 +511,31 @@ class PartTemplateManager: return True, "·软阈值" return False, "" + @staticmethod + def _match_filter_names(): + """模板比对后需丢弃的菜名(如手、空盘)。""" + try: + from dao.settings import DISH_MATCH_FILTER_NAMES + return {str(n).strip() for n in (DISH_MATCH_FILTER_NAMES or []) if str(n).strip()} + except Exception: + return {"手", "空盘"} + + def _filtered_match_result(self, name, score, feat, top_k=None, scope="filtered"): + print(f"[match] filtered template hit: {name} score={score}") + return { + "text": f"已过滤:{name}", + "is_unknown": False, + "is_filtered": True, + "label": None, + "score": float(score) if score is not None else None, + "suggested_category": name, + "filtered_as": name, + "top_k": top_k or [], + "pending_id": None, + "feature": feat, + "match_scope": scope, + } + def predict_from_feature(self, feat, image_path=None, auto_pending=True): """已有特征时直接匹配(避免待学习提交时重复提特征)。 @@ -567,25 +592,53 @@ class PartTemplateManager: if period_only: staple_names = self.staple_candidate_names() period_set = set(period_names or []) - allowed = set(staple_names) | period_set + filter_names = self._match_filter_names() + # 过滤类若本地有模板,纳入候选:先比中再丢弃,避免误认成其他菜 + with self.lock: + lib_names = {str(k).strip() for k in (self.template_library or {}).keys() if str(k).strip()} + filter_in_lib = filter_names & lib_names + allowed = set(staple_names) | period_set | filter_in_lib ranked, _, _ = ( self._rank_categories(feat, allowed_names=allowed) if allowed else ([], None, -1.0) ) - # 从合并结果里拆出主食 / 餐段各自最高分 + # 从合并结果里拆出主食 / 餐段 / 过滤类各自最高分 best_st, max_st = None, -1.0 best_p, max_p = None, -1.0 + best_f, max_f = None, -1.0 for name, score in ranked: sc = float(score) + if name in filter_in_lib and sc > max_f: + best_f, max_f = name, sc if name in staple_names and sc > max_st: best_st, max_st = name, sc if name in period_set and sc > max_p: best_p, max_p = name, sc + # 过滤类达标:直接丢弃该框(不再与菜品比优先) + f_hit, _ = self._score_hit(best_f, max_f, staple_thr, staple_soft) + # 过滤类用与主食相同阈值;也可用餐段阈值取较松者 + if not f_hit: + f_hit, _ = self._score_hit(best_f, max_f, small_thr, soft_thr) + st_hit, st_how = self._score_hit(best_st, max_st, staple_thr, staple_soft) p_hit, p_how = self._score_hit(best_p, max_p, small_thr, soft_thr) + top_k = [{"category": n, "score": s} for n, s in (ranked or [])[:3]] + + if f_hit and best_f is not None: + # 过滤类分不低于当前菜/主食命中分 → 认定应丢弃 + other_best = -1.0 + if st_hit: + other_best = max(other_best, float(max_st)) + if p_hit: + other_best = max(other_best, float(max_p)) + if float(max_f) >= other_best - float(staple_margin): + return self._filtered_match_result( + str(best_f).strip(), float(max_f), feat, top_k=top_k, scope="filtered", + ) + chosen = None how = "" scope = "period_miss" @@ -597,10 +650,12 @@ class PartTemplateManager: elif st_hit: chosen, how, scope = best_st, f"主食+餐段{st_how}", "staple" - top_k = [{"category": n, "score": s} for n, s in (ranked or [])[:3]] - if chosen is not None: score = float(max_st) if scope == "staple" else float(max_p) + if str(chosen).strip() in filter_names: + return self._filtered_match_result( + str(chosen).strip(), score, feat, top_k=top_k, scope=scope, + ) text = f"{chosen} 相似度:{score:.3f}({how})" return { "text": text, @@ -615,11 +670,19 @@ class PartTemplateManager: } sug, sug_score = None, -1.0 + if best_f is not None and float(max_f) > sug_score: + sug, sug_score = best_f, float(max_f) if best_st is not None and float(max_st) > sug_score: sug, sug_score = best_st, float(max_st) if best_p is not None and float(max_p) > sug_score: sug, sug_score = best_p, float(max_p) + # 最接近手/空盘:整框丢弃,不进未知/待学习 + if sug is not None and str(sug).strip() in filter_names: + return self._filtered_match_result( + str(sug).strip(), float(sug_score), feat, top_k=top_k, scope="period_miss", + ) + pending_id = None if auto_pending and image_path: pending_id = self.pending_store.add(image_path, feat, sug, sug_score) diff --git a/core/pending_learning_ui.py b/core/pending_learning_ui.py index 168f2c3..333e0da 100644 --- a/core/pending_learning_ui.py +++ b/core/pending_learning_ui.py @@ -738,6 +738,7 @@ class PendingLearningUIMixin: final_result = "" dish_labels = [] dishes = [] + kept_boxes = [] unknown_count = 0 for i in range(n): if progress_callback: @@ -750,12 +751,17 @@ class PendingLearningUIMixin: image_path=img_path if isinstance(img_path, str) else None, auto_pending=auto_pending, ) + if detail.get("is_filtered"): + print(f"[match] skip dish box filtered_as={detail.get('filtered_as')}") + continue if detail.get("is_unknown"): unknown_count += 1 + idx = len(dishes) + 1 dish_labels.append(format_dish_box_label(detail)) - final_result += f"【菜品{i + 1}】{format_predict_detail(detail)}\n" + final_result += f"【菜品{idx}】{format_predict_detail(detail)}\n" + kept_boxes.append(box) dishes.append({ - "index": i + 1, + "index": idx, "label": detail.get("label"), "score": detail.get("score"), "is_unknown": bool(detail.get("is_unknown")), @@ -768,10 +774,12 @@ class PendingLearningUIMixin: "feature": detail.get("feature"), }) except Exception as e: + idx = len(dishes) + 1 dish_labels.append("识别失败") - final_result += f"【菜品{i + 1}】加载/识别失败:{e}\n" + final_result += f"【菜品{idx}】加载/识别失败:{e}\n" + kept_boxes.append(box) dishes.append({ - "index": i + 1, + "index": idx, "label": None, "score": None, "is_unknown": True, @@ -792,7 +800,7 @@ class PendingLearningUIMixin: (d.get("label") or d.get("suggested_category") or f"菜品{d.get('index') or i + 1}") for i, d in enumerate(dishes) ] - labeled_path = self.build_dish_labeled_image(source_image_path, boxes, viz_labels) + labeled_path = self.build_dish_labeled_image(source_image_path, kept_boxes, viz_labels) _mark("batch: draw_labels done") except Exception as e: _mark(f"batch: draw_labels fail {e}") diff --git a/dao/settings.py b/dao/settings.py index 3cb5a1a..a3c07b8 100644 --- a/dao/settings.py +++ b/dao/settings.py @@ -125,11 +125,13 @@ DISH_LIVE_ORDER_SAME_SCENE_FRAMES = int(os.environ.get('DISH_LIVE_ORDER_SAME_SCE DISH_LIVE_ORDER_LAYOUT_IOU = float(os.environ.get('DISH_LIVE_ORDER_LAYOUT_IOU', '0.40')) DISH_LIVE_ORDER_LAYOUT_MATCH_RATIO = float(os.environ.get('DISH_LIVE_ORDER_LAYOUT_MATCH_RATIO', '0.70')) -# YOLO 检测需直接丢弃的类别(不裁剪、不识别、不上报),逗号分隔 +# 模板库比对后需丢弃的菜名(YOLO 无此类时仍生效):命中「手」「空盘」等则整框过滤,不入清单/不上报 +# 环境变量名沿用 DISH_DETECT_FILTER_NAMES DISH_DETECT_FILTER_NAMES = [ x.strip() for x in os.environ.get('DISH_DETECT_FILTER_NAMES', '手,空盘').split(',') if x.strip() ] +DISH_MATCH_FILTER_NAMES = DISH_DETECT_FILTER_NAMES # 贴边不完整框过滤:托盘刚入画只露一角时 YOLO 常会检出,直接丢弃 # 框任一边缘距画面边界 ≤ margin 像素,视为贴边 DISH_DETECT_EDGE_MARGIN_PX = int(os.environ.get('DISH_DETECT_EDGE_MARGIN_PX', '8')) diff --git a/ui/pending_learning_ui.py b/ui/pending_learning_ui.py index 206076d..2f59107 100644 --- a/ui/pending_learning_ui.py +++ b/ui/pending_learning_ui.py @@ -1041,6 +1041,7 @@ class PendingLearningUIMixin: final_result = "" dish_labels = [] dishes = [] + kept_boxes = [] unknown_count = 0 for i in range(n): if progress_callback: @@ -1054,6 +1055,11 @@ class PendingLearningUIMixin: image_path=img_path if isinstance(img_path, str) else None, auto_pending=auto_pending, ) + # 模板命中手/空盘等:整框丢弃 + if detail.get("is_filtered"): + filtered_as = detail.get("filtered_as") or detail.get("suggested_category") + print(f"[match] skip dish box filtered_as={filtered_as}") + continue if detail.get("is_unknown"): unknown_count += 1 match_scope = detail.get("match_scope") @@ -1064,10 +1070,12 @@ class PendingLearningUIMixin: label_src["det_conf"] = det_conf label_src["in_today_menu"] = in_menu label_src["match_scope"] = match_scope + idx = len(dishes) + 1 dish_labels.append(format_dish_box_label(label_src)) - final_result += f"【菜品{i + 1}】{format_predict_detail(detail)}\n" + final_result += f"【菜品{idx}】{format_predict_detail(detail)}\n" + kept_boxes.append(box) dishes.append({ - "index": i + 1, + "index": idx, "label": detail.get("label"), "score": detail.get("score"), "det_conf": det_conf, @@ -1083,10 +1091,12 @@ class PendingLearningUIMixin: "in_today_menu": in_menu, }) except Exception as e: + idx = len(dishes) + 1 dish_labels.append("识别失败") - final_result += f"【菜品{i + 1}】加载/识别失败:{e}\n" + final_result += f"【菜品{idx}】加载/识别失败:{e}\n" + kept_boxes.append(box) dishes.append({ - "index": i + 1, + "index": idx, "label": None, "score": None, "det_conf": det_conf, @@ -1109,7 +1119,7 @@ class PendingLearningUIMixin: ] viz_styles = [dish_overlay_style(d) for d in dishes] labeled_path = self.build_dish_labeled_image( - source_image_path, boxes, viz_labels, styles=viz_styles, + source_image_path, kept_boxes, viz_labels, styles=viz_styles, ) except Exception: labeled_path = None