diff --git a/core/common_classifier.py b/core/common_classifier.py index 224cf4e..e434a52 100644 --- a/core/common_classifier.py +++ b/core/common_classifier.py @@ -330,6 +330,8 @@ class PartTemplateManager: add_time=add_time, ) self.template_library[part_type].append(record) + # 学菜成功:上传到云端 template/{菜名}/ + self._upload_template_cloud_async(part_type, template_img_path) added += 1 if added: self._rebuild_sim_index() @@ -337,6 +339,15 @@ class PartTemplateManager: self._notify_learned_async(part_type) return added, len(image_paths) + @staticmethod + def _upload_template_cloud_async(dish_name, image_path): + """待学习/学菜写入本地后,异步上传到服务器 template/{菜名}/。""" + try: + from service.template_upload import upload_template_image_async + upload_template_image_async(image_path, dish_name) + except Exception as exc: + print(f'[WARN] schedule template upload failed: {exc}') + @staticmethod def _notify_learned_async(dish_name): name = (dish_name or '').strip() @@ -400,10 +411,14 @@ class PartTemplateManager: if prepared: self.repo.insert_templates_batch(prepared) self.load_templates() - # 批量导入:对成功写入的菜名去重后上报「已学习」 + # 批量导入:对成功写入的菜名去重后上报「已学习」+ 上传云端模板目录 learned_names = sorted({item['category_name'] for item in prepared if item.get('category_name')}) for name in learned_names: self._notify_learned_async(name) + for item in prepared: + self._upload_template_cloud_async( + item.get('category_name'), item.get('image_url'), + ) return len(prepared), failures def delete_template(self, part_type, template_index): diff --git a/core/detect_plate_process.py b/core/detect_plate_process.py index d694586..ca6bf15 100644 --- a/core/detect_plate_process.py +++ b/core/detect_plate_process.py @@ -30,20 +30,44 @@ class Config: GENERATE_VISUALIZATION = True # 是否生成可视化图 - # 只有 Plate 一个类别 + # 默认类别名(加载权重后会被 model.names 覆盖) LABEL_NAMES = {0: "Plate"} COLORS = {'Plate': (0, 255, 0)} + # 检测到后直接丢弃,不进入裁剪/识别 + FILTER_CLASS_NAMES = ("手", "空盘") # ======================================================== +def _load_filter_names(config): + names = getattr(config, "FILTER_CLASS_NAMES", None) + if names is None: + try: + from dao.settings import DISH_DETECT_FILTER_NAMES + names = DISH_DETECT_FILTER_NAMES + except Exception: + names = ("手", "空盘") + return {str(n).strip() for n in (names or []) if str(n).strip()} + + class YOLOv11PlateExtractor: def __init__(self, config): self.config = config weights = resolve_yolo_weights(config.MODEL_PATH) self.model = YOLO(weights) self.weights_path = weights + # 以权重内类别为准 + try: + model_names = getattr(self.model, "names", None) or {} + if isinstance(model_names, dict) and model_names: + self.config.LABEL_NAMES = {int(k): str(v) for k, v in model_names.items()} + except Exception: + pass + self.filter_class_names = _load_filter_names(config) print(f"✅ Plate 模型加载成功: {weights}") + print(f"[OK] detect classes: {self.config.LABEL_NAMES}") + if self.filter_class_names: + print(f"[OK] detect filter drop: {sorted(self.filter_class_names)}") def predict(self, source): """source 可为图片路径或 BGR ndarray。""" @@ -55,27 +79,57 @@ class YOLOv11PlateExtractor: verbose=False, )[0] + def _class_name(self, results, cls_id): + cid = int(cls_id) + names = getattr(results, "names", None) or self.config.LABEL_NAMES or {} + if isinstance(names, dict): + return str(names.get(cid, names.get(str(cid), cid))) + return str(cid) + + def _should_drop_class(self, class_name): + name = (class_name or "").strip() + if not name: + return False + return name in self.filter_class_names + + def iter_kept_detections(self, results): + """产出保留的检测框:(box, conf, class_name)。手/空盘等已过滤。""" + if results is None or results.boxes is None: + return + boxes = results.boxes.xyxy.cpu().numpy() + confs = results.boxes.conf.cpu().numpy() + if getattr(results.boxes, "cls", None) is not None: + clss = results.boxes.cls.cpu().numpy() + else: + clss = [0] * len(boxes) + dropped = 0 + for box, conf, cls_id in zip(boxes, confs, clss): + cname = self._class_name(results, cls_id) + if self._should_drop_class(cname): + dropped += 1 + continue + yield box, float(conf), cname + if dropped: + print(f"[detect] filtered {dropped} box(es) in {sorted(self.filter_class_names)}") + def crop_plate(self, image, results): cropped_regions = [] - if results.boxes is not None: - boxes = results.boxes.xyxy.cpu().numpy() - confs = results.boxes.conf.cpu().numpy() - - h, w = image.shape[:2] - for box, conf in zip(boxes, confs): - x1, y1, x2, y2 = map(int, box) - x1 = max(0, x1) - y1 = max(0, y1) - x2 = min(w, x2) - y2 = min(h, y2) - if x2 > x1 and y2 > y1: - crop = image[y1:y2, x1:x2] - if crop.size > 0: - cropped_regions.append({ - "image": crop.copy(), - "conf": float(conf), - "box": [x1, y1, x2, y2], - }) + h, w = image.shape[:2] + for box, conf, cname in self.iter_kept_detections(results): + x1, y1, x2, y2 = map(int, box) + x1 = max(0, x1) + y1 = max(0, y1) + x2 = min(w, x2) + y2 = min(h, y2) + if x2 > x1 and y2 > y1: + crop = image[y1:y2, x1:x2] + if crop.size > 0: + cropped_regions.append({ + "image": crop.copy(), + "conf": float(conf), + "box": [x1, y1, x2, y2], + "class_name": cname, + }) return cropped_regions # 标注默认:大库红框 + 白字;今日菜单由 style 覆盖为绿框绿字 @@ -138,21 +192,18 @@ class YOLOv11PlateExtractor: def visualize_results(self, image, results, save_path, labels=None): """ - 在检测框上绘制标签。 - labels 为 None 时画 Plate+置信度;传入列表时画菜品名称(认菜不认盘展示)。 + 在检测框上绘制标签(已过滤手/空盘)。 + labels 为 None 时画 类别+置信度;传入列表时画菜品名称(认菜不认盘展示)。 """ img = Image.fromarray(cv2.cvtColor(image, cv2.COLOR_BGR2RGB)) font = self._load_font() - - if results.boxes is not None: - boxes = results.boxes.xyxy.cpu().numpy() - confs = results.boxes.conf.cpu().numpy() - for i, (box, conf) in enumerate(zip(boxes, confs)): - if labels is not None and i < len(labels): - text = str(labels[i]) - else: - text = f"Plate {conf:.2f}" - img = self._draw_box_and_chip(img, box, text, font) + kept = list(self.iter_kept_detections(results)) + for i, (box, conf, cname) in enumerate(kept): + if labels is not None and i < len(labels): + text = str(labels[i]) + else: + text = f"{cname} {conf:.2f}" + img = self._draw_box_and_chip(img, box, text, font) os.makedirs(os.path.dirname(save_path) or ".", exist_ok=True) img.save(save_path) return save_path @@ -246,6 +297,7 @@ class YOLOv11PlateExtractor: "image": img_crop, "path": path, "conf": region.get("conf"), + "class_name": region.get("class_name"), }) boxes.append(region["box"]) diff --git a/dao/meal_schedule.py b/dao/meal_schedule.py index 79d82cf..0e5234d 100644 --- a/dao/meal_schedule.py +++ b/dao/meal_schedule.py @@ -164,60 +164,60 @@ class MealScheduleStore: return dish.get('price') return None - # def current_period(self, now=None): - # """ - # 根据当前时间匹配餐段。 - # 返回 dict: mealPeriodId/name/startTime/endTime;不在任何餐段则 None。 - # """ - # now = now or datetime.now() - # with self._lock: - # raw = self._raw - # date = self._date - # today = now.strftime('%Y-%m-%d') - # if not raw or date != today: - # return None - # - # minutes = now.hour * 60 + now.minute - # # 优先用 bases[].meals 的时段(与菜品绑定);否则用顶层 mealPeriods - # periods = [] - # for base in raw.get('bases') or []: - # for meal in base.get('meals') or []: - # periods.append({ - # 'mealPeriodId': meal.get('mealPeriodId'), - # 'mealPeriodName': meal.get('mealPeriodName') or meal.get('name'), - # 'startTime': meal.get('startTime'), - # 'endTime': meal.get('endTime'), - # }) - # if periods: - # break - # if not periods: - # for p in raw.get('mealPeriods') or []: - # periods.append({ - # 'mealPeriodId': p.get('id'), - # 'mealPeriodName': p.get('name'), - # 'startTime': p.get('startTime'), - # 'endTime': p.get('endTime'), - # }) - # for p in periods: - # if _in_period(minutes, p.get('startTime'), p.get('endTime')): - # return p - # return None - - # 测试代码 def current_period(self, now=None): + """ + 根据当前时间匹配餐段。 + 返回 dict: mealPeriodId/name/startTime/endTime;不在任何餐段则 None。 + """ now = now or datetime.now() + with self._lock: + raw = self._raw + date = self._date + today = now.strftime('%Y-%m-%d') + if not raw or date != today: + return None + minutes = now.hour * 60 + now.minute - # 写死餐段(本地优先) - HARD_PERIODS = [ - {'mealPeriodId': 1, 'mealPeriodName': '早餐', 'startTime': '07:00', 'endTime': '09:00'}, - {'mealPeriodId': 2, 'mealPeriodName': '中餐', 'startTime': '12:30', 'endTime': '19:30'}, # 可自行改 - {'mealPeriodId': 3, 'mealPeriodName': '晚餐', 'startTime': '19:00', 'endTime': '20:00'}, - ] - for p in HARD_PERIODS: - if _in_period(minutes, p['startTime'], p['endTime']): + # 优先用 bases[].meals 的时段(与菜品绑定);否则用顶层 mealPeriods + periods = [] + for base in raw.get('bases') or []: + for meal in base.get('meals') or []: + periods.append({ + 'mealPeriodId': meal.get('mealPeriodId'), + 'mealPeriodName': meal.get('mealPeriodName') or meal.get('name'), + 'startTime': meal.get('startTime'), + 'endTime': meal.get('endTime'), + }) + if periods: + break + if not periods: + for p in raw.get('mealPeriods') or []: + periods.append({ + 'mealPeriodId': p.get('id'), + 'mealPeriodName': p.get('name'), + 'startTime': p.get('startTime'), + 'endTime': p.get('endTime'), + }) + for p in periods: + if _in_period(minutes, p.get('startTime'), p.get('endTime')): return p return None + # 测试代码 + # def current_period(self, now=None): + # now = now or datetime.now() + # minutes = now.hour * 60 + now.minute + # # 写死餐段(本地优先) + # HARD_PERIODS = [ + # {'mealPeriodId': 1, 'mealPeriodName': '早餐', 'startTime': '07:00', 'endTime': '09:00'}, + # {'mealPeriodId': 2, 'mealPeriodName': '中餐', 'startTime': '12:30', 'endTime': '19:30'}, # 可自行改 + # {'mealPeriodId': 3, 'mealPeriodName': '晚餐', 'startTime': '19:00', 'endTime': '20:00'}, + # ] + # for p in HARD_PERIODS: + # if _in_period(minutes, p['startTime'], p['endTime']): + # return p + # return None + def current_period_dish_names(self, now=None): """当前餐段菜名集合(小模版库)。不在餐段 / 无排餐 → 空集。""" now = now or datetime.now() diff --git a/dao/settings.py b/dao/settings.py index 9158f79..e944256 100644 --- a/dao/settings.py +++ b/dao/settings.py @@ -96,7 +96,7 @@ DISH_STAPLE_NAMES = [ if x.strip() ] DISH_STAPLE_LIB_THRESHOLD = float(os.environ.get('DISH_STAPLE_LIB_THRESHOLD', '0.80')) -DISH_STAPLE_LIB_SOFT_THRESHOLD = float(os.environ.get('DISH_STAPLE_LIB_SOFT_THRESHOLD', '0.75')) +DISH_STAPLE_LIB_SOFT_THRESHOLD = float(os.environ.get('DISH_STAPLE_LIB_SOFT_THRESHOLD', '0.81')) # 主食与餐段菜都达标时:主食分 ≥ 菜分 - margin 则优先认主食(减轻主食被菜品抢走) DISH_STAPLE_PRIORITY_MARGIN = float(os.environ.get('DISH_STAPLE_PRIORITY_MARGIN', '0.03')) # 每餐开始前多少分钟拉取排餐(默认 60 = 开餐前 1 小时) @@ -118,6 +118,17 @@ DISH_LIVE_MOTION_SIM_WITH_ORDER = float(os.environ.get('DISH_LIVE_MOTION_SIM_WIT DISH_LIVE_ORDER_DEBOUNCE_SECONDS = float(os.environ.get('DISH_LIVE_ORDER_DEBOUNCE_SECONDS', '2')) # 无餐盘持续该秒数后清场解锁,允许下一盘(含相同菜名)重新存图/上报 DISH_LIVE_ORDER_CLEAR_HOLD_SECONDS = float(os.environ.get('DISH_LIVE_ORDER_CLEAR_HOLD_SECONDS', '1.5')) +# 短时同场景去重:窗口内即使个别菜名识别不同,只要检测框布局相近也视为同一单(不重复上报) +DISH_LIVE_ORDER_SAME_SCENE_SECONDS = float(os.environ.get('DISH_LIVE_ORDER_SAME_SCENE_SECONDS', '1.5')) +# 框匹配 IoU 阈值;匹配对数占比阈值(0~1) +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 检测需直接丢弃的类别(不裁剪、不识别、不上报),逗号分隔 +DISH_DETECT_FILTER_NAMES = [ + x.strip() for x in os.environ.get('DISH_DETECT_FILTER_NAMES', '手,空盘').split(',') + if x.strip() +] # 用本地视频文件夹代替相机实时流(调试/回放,主要给海康入口用)。 # 1=读 DISH_VIDEO_SOURCE_DIR,0=读相机。usb_camera_app 固定读 USB 相机,不受此项影响。 @@ -131,9 +142,9 @@ DISH_STREAM_RECORD_ENABLED = os.environ.get( 'DISH_STREAM_RECORD_ENABLED', _STREAM_RECORD_DEFAULT, ) == '1' DISH_STREAM_RECORD_DIR = os.environ.get('DISH_STREAM_RECORD_DIR', 'videos') -DISH_STREAM_RETENTION_MINUTES = float(os.environ.get('DISH_STREAM_RETENTION_MINUTES', '30')) +DISH_STREAM_RETENTION_MINUTES = float(os.environ.get('DISH_STREAM_RETENTION_MINUTES', '3000')) # 单段时长(秒),默认 5 分钟一切,便于清理 -DISH_STREAM_SEGMENT_SECONDS = float(os.environ.get('DISH_STREAM_SEGMENT_SECONDS', '300')) +DISH_STREAM_SEGMENT_SECONDS = float(os.environ.get('DISH_STREAM_SEGMENT_SECONDS', '350')) DISH_STREAM_RECORD_FPS = float(os.environ.get('DISH_STREAM_RECORD_FPS', '10')) DISH_STREAM_MAX_WIDTH = int(os.environ.get('DISH_STREAM_MAX_WIDTH', '1280')) diff --git a/dish_server/api_server.py b/dish_server/api_server.py index 056f34e..96db710 100644 --- a/dish_server/api_server.py +++ b/dish_server/api_server.py @@ -281,14 +281,32 @@ class ApiHandler(BaseHTTPRequestHandler): filename, data = blob media_type = (fields.get("type") or "source").strip().lower() if media_type not in ALLOWED_TYPES: - status, out = _err(400, 40001, "type 必须是 source / labeled / crop") + status, out = _err( + 400, 40001, "type 必须是 source / labeled / crop / template", + ) self._send(status, out) return base_id = fields.get("base_id") or self.headers.get("X-Base-Code") or "BASE" request_id = fields.get("request_id") or "" + # 学菜模板:落盘 template/{菜名}/;dish_name / category / name 均可 + dish_name = ( + fields.get("dish_name") + or fields.get("category") + or fields.get("name") + or "" + ).strip() + if media_type == "template" and not dish_name: + status, out = _err(400, 40001, "type=template 时必须传 dish_name") + self._send(status, out) + return try: saved = _store.save_bytes( - data, filename, media_type=media_type, base_id=base_id, request_id=request_id, + data, + filename, + media_type=media_type, + base_id=base_id, + request_id=request_id, + dish_name=dish_name or None, ) except Exception as exc: status, out = _err(500, 50000, str(exc)) diff --git a/dish_server/media_store.py b/dish_server/media_store.py index fb3c21e..d9a80c2 100644 --- a/dish_server/media_store.py +++ b/dish_server/media_store.py @@ -30,7 +30,8 @@ MEDIA_ROOT = _DEFAULT_MEDIA_ROOT MEDIA_PUBLIC_BASE = os.environ.get("DISH_MEDIA_PUBLIC_BASE", CLOUD_MEDIA_PUBLIC_BASE).rstrip("/") _SAFE = re.compile(r"[^A-Za-z0-9._-]+") -ALLOWED_TYPES = ("source", "labeled", "crop") +# template:学菜模板图,落盘 template/{菜名}/xxx.jpg +ALLOWED_TYPES = ("source", "labeled", "crop", "template") ALLOWED_EXT = {".jpg", ".jpeg", ".png", ".bmp", ".webp"} @@ -39,6 +40,15 @@ def _safe_token(value, fallback="x"): return text or fallback +def _safe_dish_dirname(value, fallback="unknown"): + """菜名目录:保留中文,去掉路径非法字符。""" + text = str(value or "").strip() + for ch in '<>:"/\\|?*\x00': + text = text.replace(ch, "_") + text = text.strip(" .")[:80] + return text or fallback + + def public_url(rel_path): rel = str(rel_path or "").replace("\\", "/").lstrip("/") return f"{MEDIA_PUBLIC_BASE}/{rel}" @@ -62,7 +72,15 @@ class MediaStore: self.public_base = (public_base or MEDIA_PUBLIC_BASE).rstrip("/") os.makedirs(self.root, exist_ok=True) - def save_bytes(self, data, filename, media_type="source", base_id="BASE", request_id=None): + def save_bytes( + self, + data, + filename, + media_type="source", + base_id="BASE", + request_id=None, + dish_name=None, + ): if not data: raise ValueError("empty file") media_type = (media_type or "source").strip().lower() @@ -71,10 +89,22 @@ class MediaStore: ext = Path(filename or "").suffix.lower() or ".jpg" if ext not in ALLOWED_EXT: ext = ".jpg" - req = _safe_token(request_id or f"REQ_{int(time.time() * 1000)}") - base = _safe_token(base_id or "BASE") - stem = _safe_token(Path(filename or media_type).stem, media_type) - rel = f"{base}/{req}/{media_type}_{stem}{ext}" + + if media_type == "template": + # 学菜模板:/data/tpweb/template/{菜名}/文件名 + dish = _safe_dish_dirname(dish_name or request_id or "unknown") + orig = Path(filename or f"{dish}{ext}").name + for ch in '<>:"/\\|?*\x00': + orig = orig.replace(ch, "_") + if not Path(orig).suffix: + orig = f"{orig}{ext}" + rel = f"template/{dish}/{orig}" + else: + req = _safe_token(request_id or f"REQ_{int(time.time() * 1000)}") + base = _safe_token(base_id or "BASE") + stem = _safe_token(Path(filename or media_type).stem, media_type) + rel = f"{base}/{req}/{media_type}_{stem}{ext}" + dest = local_path_under(self.root, rel) os.makedirs(os.path.dirname(dest), exist_ok=True) with open(dest, "wb") as fh: @@ -87,7 +117,14 @@ class MediaStore: "type": media_type, } - def save_file(self, src_path, media_type="source", base_id="BASE", request_id=None): + def save_file( + self, + src_path, + media_type="source", + base_id="BASE", + request_id=None, + dish_name=None, + ): if not src_path or not os.path.isfile(src_path): return None with open(src_path, "rb") as fh: @@ -98,6 +135,7 @@ class MediaStore: media_type=media_type, base_id=base_id, request_id=request_id, + dish_name=dish_name, ) diff --git a/hik_camera_app.py b/hik_camera_app.py index 5a6be17..0b74ceb 100644 --- a/hik_camera_app.py +++ b/hik_camera_app.py @@ -114,6 +114,24 @@ class ImageClassifierDemo(BaseDishApp): print(f"海康取帧失败: {e}") return None + def get_video_progress(self): + if not DISH_USE_VIDEO_FOLDER or self._video_source is None: + return None + try: + return self._video_source.get_progress() + except Exception: + return None + + def seek_video_ratio(self, ratio): + if not DISH_USE_VIDEO_FOLDER or self._video_source is None: + return False + with self._frame_lock: + try: + return bool(self._video_source.seek_ratio(ratio)) + except Exception as e: + print(f"视频跳转失败: {e}") + return False + def take_live_frame(self): """识别用:优先复用预览缓存帧;不落盘(有订单时由 _save_order_image 写入)。""" frame = None diff --git a/ui/classifier_ui.py b/ui/classifier_ui.py index 4c414e4..03a4c4d 100644 --- a/ui/classifier_ui.py +++ b/ui/classifier_ui.py @@ -51,6 +51,9 @@ from dao.settings import ( DISH_LIVE_MOTION_SIM_WITH_ORDER, DISH_LIVE_ORDER_DEBOUNCE_SECONDS, DISH_LIVE_ORDER_CLEAR_HOLD_SECONDS, + DISH_LIVE_ORDER_SAME_SCENE_SECONDS, + DISH_LIVE_ORDER_LAYOUT_IOU, + DISH_LIVE_ORDER_LAYOUT_MATCH_RATIO, DISH_STREAM_RECORD_ENABLED, DISH_STREAM_RECORD_DIR, DISH_STREAM_RETENTION_MINUTES, @@ -154,9 +157,13 @@ class BaseDishApp(PendingLearningUIMixin): self._live_stable_snapshot = None # 稳定期间保留的 dishes,供写入 self._last_reported_sig = None # 实时上报去重:相同菜名集合只报一次 self._last_order_dish_sig = None # 仅菜名签名:菜未变不重复存图/统计 + self._last_order_boxes = None # 上一单检测框,用于短时同场景去重(忽略菜名抖动) self._last_order_at = 0.0 self._order_clear_since = None # 无盘连续计时起点 self._order_clear_hold = float(DISH_LIVE_ORDER_CLEAR_HOLD_SECONDS) + self._order_same_scene_seconds = float(DISH_LIVE_ORDER_SAME_SCENE_SECONDS) + self._order_layout_iou = float(DISH_LIVE_ORDER_LAYOUT_IOU) + self._order_layout_match_ratio = float(DISH_LIVE_ORDER_LAYOUT_MATCH_RATIO) self._last_detect_fp = None # 识别后锚点指纹(静止等待运动;仅餐盘区域) self._last_live_dishes = None # 上一轮识别结果(跳过检测时推进稳定计时) self._last_motion_boxes = None # 上一轮餐盘框,运动判定只看菜品区域 @@ -1458,11 +1465,97 @@ class BaseDishApp(PendingLearningUIMixin): justify=tk.CENTER, ) self.img_label.pack(fill=tk.BOTH, expand=True) + + # 视频回放进度(仅 get_video_progress 可用时显示) + self._video_prog_row = tk.Frame(panel, bg=c["bg"]) + self._video_prog_row.grid(row=2, column=0, sticky="ew", pady=(6, 0)) + self._video_prog_label = tk.Label( + self._video_prog_row, text="", anchor="w", + bg=c["bg"], fg=c["muted"], font=FONTS.get("caption", FONTS["h2"]), + ) + self._video_prog_label.pack(fill=tk.X, pady=(0, 2)) + self._video_prog_var = tk.DoubleVar(value=0.0) + self._video_prog_dragging = False + self._video_prog_scale = ttk.Scale( + self._video_prog_row, from_=0.0, to=1.0, orient=tk.HORIZONTAL, + variable=self._video_prog_var, style="Horizontal.TScale", + command=self._on_video_progress_drag, + ) + self._video_prog_scale.pack(fill=tk.X) + self._video_prog_scale.bind("", self._on_video_progress_press) + self._video_prog_scale.bind("", self._on_video_progress_release) + self._video_prog_row.grid_remove() + self._loading = False self._loading_job = None self._loading_frame = 0 return panel + def _video_progress_supported(self): + return callable(getattr(self, "get_video_progress", None)) + + def _show_video_progress_bar(self, show=True): + row = getattr(self, "_video_prog_row", None) + if row is None: + return + try: + if show and self._video_progress_supported(): + row.grid() + else: + row.grid_remove() + except Exception: + pass + + def _on_video_progress_press(self, _event=None): + self._video_prog_dragging = True + + def _on_video_progress_drag(self, _value=None): + # 拖动中只更新本地显示,松手再 seek + return + + def _on_video_progress_release(self, _event=None): + self._video_prog_dragging = False + seek = getattr(self, "seek_video_ratio", None) + if not callable(seek): + return + try: + ratio = float(self._video_prog_var.get()) + except Exception: + return + try: + seek(ratio) + except Exception as exc: + print(f"[video] seek failed: {exc}") + + def _update_video_progress_ui(self): + if not self._video_progress_supported(): + return + row = getattr(self, "_video_prog_row", None) + if row is None: + return + try: + info = self.get_video_progress() + except Exception: + info = None + if not info: + return + try: + self._video_prog_row.grid() + except Exception: + pass + label = info.get("label") or info.get("file") or "" + try: + self._video_prog_label.config(text=label) + except Exception: + pass + if getattr(self, "_video_prog_dragging", False): + return + try: + ratio = float(info.get("ratio") or 0.0) + self._video_prog_var.set(max(0.0, min(1.0, ratio))) + except Exception: + pass + def _build_pos_bottom(self, c): bar = tk.Frame(self.root, bg=c["pos_bottom_bar"], height=SIZES.get("pos_bottom_h", 72)) bar.pack(side=tk.BOTTOM, fill=tk.X) @@ -1958,6 +2051,7 @@ class BaseDishApp(PendingLearningUIMixin): """重置订单防重门闩(清场解锁 / 清空清单 / 启停扫描)。""" self._order_clear_since = None self._last_order_dish_sig = None + self._last_order_boxes = None self._last_order_at = 0.0 self._last_reported_sig = None @@ -1982,26 +2076,107 @@ class BaseDishApp(PendingLearningUIMixin): print(f"[order] 清场,已点清单可被下一盘覆盖(无盘 ≥ {hold:g}s)") return True - def _should_record_new_order(self, dishes): - """菜品未变 → 不存图、不重新统计订单(含 is_unknown 未知菜)。 + @staticmethod + def _box_iou(a, b): + try: + ax1, ay1, ax2, ay2 = [float(x) for x in a[:4]] + bx1, by1, bx2, by2 = [float(x) for x in b[:4]] + except Exception: + return 0.0 + ix1, iy1 = max(ax1, bx1), max(ay1, by1) + ix2, iy2 = min(ax2, bx2), min(ay2, by2) + iw, ih = max(0.0, ix2 - ix1), max(0.0, iy2 - iy1) + inter = iw * ih + if inter <= 0: + return 0.0 + area_a = max(0.0, ax2 - ax1) * max(0.0, ay2 - ay1) + area_b = max(0.0, bx2 - bx1) * max(0.0, by2 - by1) + union = area_a + area_b - inter + return float(inter / union) if union > 0 else 0.0 - 例:手在中间晃动再次触发识别,但菜名组合未变 → 保持当前单。 + def _extract_order_boxes(self, dishes): + boxes = [] + for d in dishes or []: + box = d.get("box") + if isinstance(box, (list, tuple)) and len(box) >= 4: + try: + boxes.append([float(x) for x in box[:4]]) + except Exception: + continue + return boxes + + def _order_layouts_similar(self, dishes, last_boxes): + """检测框布局是否相近(忽略菜名)。用于 1s 内同图不同标签去重。""" + cur = self._extract_order_boxes(dishes) + prev = list(last_boxes or []) + if not cur or not prev: + return False + # 盘数差太多,不算同场景 + if abs(len(cur) - len(prev)) > 1: + return False + iou_thr = float(getattr(self, "_order_layout_iou", DISH_LIVE_ORDER_LAYOUT_IOU) or 0.40) + match_ratio = float( + getattr(self, "_order_layout_match_ratio", DISH_LIVE_ORDER_LAYOUT_MATCH_RATIO) or 0.70 + ) + used = set() + matched = 0 + for a in cur: + best_i, best_iou = -1, 0.0 + for i, b in enumerate(prev): + if i in used: + continue + iou = self._box_iou(a, b) + if iou > best_iou: + best_iou, best_i = iou, i + if best_i >= 0 and best_iou >= iou_thr: + used.add(best_i) + matched += 1 + need = max(len(cur), len(prev)) + if need <= 0: + return False + return (matched / float(need)) >= match_ratio + + def _remember_order_gate(self, order_sig, dishes): + self._last_order_dish_sig = order_sig + self._last_order_boxes = self._extract_order_boxes(dishes) + self._last_order_at = time.time() + + def _should_record_new_order(self, dishes): + """菜品未变 / 短时同场景 → 不存图、不重新统计订单。 + + - 菜名组合相同:始终视为同一单(直到清场) + - 短时窗口内菜名略有不同,但检测框布局相近:仍视为同一盘(剔除重复上报) 清场解锁后 `_last_order_dish_sig` 已被清空,相同菜名可再报一单。 """ sig = self._order_dish_signature(dishes) if not sig: return False, sig last_sig = getattr(self, "_last_order_dish_sig", None) + last_at = float(getattr(self, "_last_order_at", 0) or 0) + now = time.time() + if sig == last_sig: debounce = float( getattr(self, "_order_debounce_seconds", DISH_LIVE_ORDER_DEBOUNCE_SECONDS) or 0.5 ) - last_at = float(getattr(self, "_last_order_at", 0) or 0) - if last_at and (time.time() - last_at) < debounce: + if last_at and (now - last_at) < debounce: print(f"[live] {debounce:g}s 内菜品未变,跳过存图/订单统计") else: print("[live] 菜品未变,跳过存图/订单统计") return False, sig + + # 菜名不同:短时内若框布局仍像同一盘,也不重复上报 + same_scene_s = float( + getattr(self, "_order_same_scene_seconds", DISH_LIVE_ORDER_SAME_SCENE_SECONDS) or 1.5 + ) + if last_at and same_scene_s > 0 and (now - last_at) <= same_scene_s: + last_boxes = getattr(self, "_last_order_boxes", None) + if self._order_layouts_similar(dishes, last_boxes): + print( + f"[live] {same_scene_s:g}s 内同场景(框布局相近,忽略菜名差异)," + f"跳过重复上报" + ) + return False, sig return True, sig def _update_live_stability(self, dishes): @@ -2360,6 +2535,7 @@ class BaseDishApp(PendingLearningUIMixin): with self._live_lock: self._latest_frame = frame self.show_preview_frame(frame) + self._update_video_progress_ui() try: self._stream_recorder.write(frame) except Exception as rec_exc: @@ -2746,8 +2922,7 @@ class BaseDishApp(PendingLearningUIMixin): schedule_check=False, dedupe=True, ) if reported: - self._last_order_dish_sig = order_sig - self._last_order_at = time.time() + self._remember_order_gate(order_sig, dishes) unk_n = sum(1 for d in dishes if d.get("is_unknown")) print( f"[order] 本单已上报 known={len(known_dishes)} " @@ -2755,8 +2930,7 @@ class BaseDishApp(PendingLearningUIMixin): ) elif order_sig: # dedupe 跳过也记为当前单,避免反复落盘 - self._last_order_dish_sig = order_sig - self._last_order_at = time.time() + self._remember_order_gate(order_sig, dishes) else: order_unchanged = True print(f"[live] 菜品未变,跳过存图与订单统计 sig={order_sig}") @@ -3265,14 +3439,22 @@ class BaseDishApp(PendingLearningUIMixin): self.camera_add_btn.config(state=tk.NORMAL) except Exception: pass + self._show_video_progress_bar(True) self.start_live_scan() self._refresh_record_btn() def _update_camera_ui_fail(self): name = self.camera_display_name - tip = "相机断开,请检查USB设备" if "USB" in name else f"{name}未连接,请检查设备" + if self._video_progress_supported() or "视频" in name: + from dao.settings import DISH_VIDEO_SOURCE_DIR + tip = f"视频未就绪,请将录像放入 {os.path.abspath(DISH_VIDEO_SOURCE_DIR)}" + elif "USB" in name: + tip = "相机断开,请检查USB设备" + else: + tip = f"{name}未连接,请检查设备" self.stop_live_scan() self.stop_preview_loop() + self._show_video_progress_bar(False) self._set_result_text(tip, "err") if hasattr(self, "camera_badge"): self.camera_badge.set(f"{name}未连接", "err")