过滤手和空盘类别

This commit is contained in:
wanghanbo 2026-10-14 16:03:55 +08:00
parent a095f0b776
commit bf98a717aa
4 changed files with 98 additions and 15 deletions

View File

@ -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)

View File

@ -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}")

View File

@ -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'))

View File

@ -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