diff --git a/dao/settings.py b/dao/settings.py index 52749f1..9d9747c 100644 --- a/dao/settings.py +++ b/dao/settings.py @@ -136,6 +136,25 @@ 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')) +# 托盘到位窄条:只看结算区底边一条长条区域的颜色变化 +# 变化大 → 托盘到位;连续静止若干帧 → 开始识别;窄条恢复接近空台 → 解锁下一单 +DISH_TRAY_STRIP_ENABLED = os.environ.get('DISH_TRAY_STRIP_ENABLED', '1') == '1' +# 归一化 ROI:x1,y1,x2,y2(相对整帧 0~1);默认对应结算区底边长条 +DISH_TRAY_STRIP_ROI = os.environ.get('DISH_TRAY_STRIP_ROI', '0.18,0.70,0.82,0.78') +# 相对空台变化量阈值(0~1,越大越不敏感) +DISH_TRAY_STRIP_CHANGE_THR = float(os.environ.get('DISH_TRAY_STRIP_CHANGE_THR', '0.10')) +# 与空台相似度 ≥ 此值视为已清场(托盘拿走) +DISH_TRAY_STRIP_EMPTY_SIM = float(os.environ.get('DISH_TRAY_STRIP_EMPTY_SIM', '0.93')) +# 到位后连续静止多少帧才触发识别 +DISH_TRAY_STRIP_STILL_FRAMES = int(os.environ.get('DISH_TRAY_STRIP_STILL_FRAMES', '4')) +# 预览上画出窄条区域 +DISH_TRAY_STRIP_SHOW_OVERLAY = os.environ.get('DISH_TRAY_STRIP_SHOW_OVERLAY', '1') == '1' +# 空台基准 / ROI 落盘路径(设置里「采空台」「画窄条」会覆盖) +DISH_TRAY_STRIP_CONFIG_PATH = os.environ.get( + 'DISH_TRAY_STRIP_CONFIG_PATH', + os.path.join(os.path.dirname(os.path.dirname(__file__)), 'data', 'tray_strip_config.json'), +) + # 模板库比对后需丢弃的菜名(YOLO 无此类时仍生效):命中「手」「空盘」等则整框过滤,不入清单/不上报 # 环境变量名沿用 DISH_DETECT_FILTER_NAMES DISH_DETECT_FILTER_NAMES = [ diff --git a/ui/classifier_ui.py b/ui/classifier_ui.py index ae41b66..5906dd2 100644 --- a/ui/classifier_ui.py +++ b/ui/classifier_ui.py @@ -54,6 +54,13 @@ from dao.settings import ( DISH_LIVE_ORDER_SAME_SCENE_FRAMES, DISH_LIVE_ORDER_LAYOUT_IOU, DISH_LIVE_ORDER_LAYOUT_MATCH_RATIO, + DISH_TRAY_STRIP_ENABLED, + DISH_TRAY_STRIP_ROI, + DISH_TRAY_STRIP_CHANGE_THR, + DISH_TRAY_STRIP_EMPTY_SIM, + DISH_TRAY_STRIP_STILL_FRAMES, + DISH_TRAY_STRIP_SHOW_OVERLAY, + DISH_TRAY_STRIP_CONFIG_PATH, DISH_STREAM_RECORD_ENABLED, DISH_STREAM_RECORD_DIR, DISH_STREAM_RETENTION_MINUTES, @@ -179,6 +186,26 @@ class BaseDishApp(PendingLearningUIMixin): self._motion_settle_fp = None self._motion_still_count = 0 # settling 期间已连续相似的帧数 self._yolo_trigger_pending = False + # 托盘窄条到位门闩:empty_wait → occupied_settle → wait_clear + self._tray_strip_enabled = bool(DISH_TRAY_STRIP_ENABLED) + self._tray_strip_roi = self._parse_tray_strip_roi(DISH_TRAY_STRIP_ROI) + self._tray_strip_change_thr = float(DISH_TRAY_STRIP_CHANGE_THR) + self._tray_strip_empty_sim = float(DISH_TRAY_STRIP_EMPTY_SIM) + self._tray_strip_still_frames = max(1, int(DISH_TRAY_STRIP_STILL_FRAMES)) + self._tray_strip_show_overlay = bool(DISH_TRAY_STRIP_SHOW_OVERLAY) + self._tray_strip_config_path = DISH_TRAY_STRIP_CONFIG_PATH + self._tray_strip_baseline = None + self._tray_strip_state = "empty_wait" + self._tray_strip_prev_fp = None + self._tray_strip_settle_fp = None + self._tray_strip_still_count = 0 + self._tray_strip_occupied = False + self._tray_strip_draw_mode = False + self._tray_strip_drag_start = None + self._tray_strip_drag_end = None + self._preview_src_size = None + self._preview_disp_size = None + self._load_tray_strip_config() self._stream_recorder = StreamRecorder( root_dir=DISH_STREAM_RECORD_DIR, retention_minutes=DISH_STREAM_RETENTION_MINUTES, @@ -1060,6 +1087,93 @@ class BaseDishApp(PendingLearningUIMixin): Divider(tab_cfg, bg=COLORS["bg"], padx=18, pady=14).pack(fill=tk.X) + tk.Label(tab_cfg, text="托盘到位窄条", bg=COLORS["bg"], fg=COLORS["text"], + font=FONTS["h2"]).pack(anchor=tk.W, padx=18) + tk.Label( + tab_cfg, + text="在结算区底边画一条长窄区域:颜色相对空台变化大=托盘到位,放稳后识别;窄条恢复空台后解锁下一单。", + bg=COLORS["bg"], fg=COLORS["muted"], font=FONTS["small"], wraplength=700, + justify=tk.LEFT, + ).pack(anchor=tk.W, padx=18, pady=(2, 0)) + + strip_row = tk.Frame(tab_cfg, bg=COLORS["bg"]) + strip_row.pack(fill=tk.X, padx=18, pady=(8, 0)) + strip_en = tk.BooleanVar(value=bool(getattr(self, "_tray_strip_enabled", False))) + strip_show = tk.BooleanVar(value=bool(getattr(self, "_tray_strip_show_overlay", True))) + ttk.Checkbutton(strip_row, text="启用窄条触发", variable=strip_en).pack(side=tk.LEFT) + ttk.Checkbutton(strip_row, text="预览显示窄条", variable=strip_show).pack(side=tk.LEFT, padx=(12, 0)) + + thr_row = tk.Frame(tab_cfg, bg=COLORS["bg"]) + thr_row.pack(fill=tk.X, padx=18, pady=(8, 0)) + tk.Label(thr_row, text="变化阈值", bg=COLORS["bg"], fg=COLORS["text_body"], + font=FONTS["ui"]).pack(side=tk.LEFT) + change_var = tk.DoubleVar(value=float(getattr(self, "_tray_strip_change_thr", 0.10))) + ttk.Spinbox(thr_row, from_=0.02, to=0.50, increment=0.01, width=6, + textvariable=change_var, style="Large.TSpinbox").pack(side=tk.LEFT, padx=6) + tk.Label(thr_row, text="静止帧数", bg=COLORS["bg"], fg=COLORS["text_body"], + font=FONTS["ui"]).pack(side=tk.LEFT, padx=(12, 0)) + still_var = tk.IntVar(value=int(getattr(self, "_tray_strip_still_frames", 4))) + ttk.Spinbox(thr_row, from_=1, to=30, increment=1, width=5, + textvariable=still_var, style="Large.TSpinbox").pack(side=tk.LEFT, padx=6) + + roi = getattr(self, "_tray_strip_roi", (0.18, 0.70, 0.82, 0.78)) + roi_lbl = tk.Label( + tab_cfg, + text=f"当前 ROI:{roi[0]:.2f},{roi[1]:.2f},{roi[2]:.2f},{roi[3]:.2f}" + + ("|已采空台" if getattr(self, "_tray_strip_baseline", None) is not None else "|未采空台"), + bg=COLORS["bg"], fg=COLORS["muted"], font=FONTS["small"], + ) + roi_lbl.pack(anchor=tk.W, padx=18, pady=(6, 0)) + + def _apply_strip(): + self._tray_strip_enabled = bool(strip_en.get()) + self._tray_strip_show_overlay = bool(strip_show.get()) + try: + self._tray_strip_change_thr = min(0.5, max(0.02, float(change_var.get()))) + except Exception: + pass + try: + self._tray_strip_still_frames = max(1, int(still_var.get())) + except Exception: + pass + self._reset_tray_strip_gate() + self._save_tray_strip_config() + tip = "托盘窄条已启用" if self._tray_strip_enabled else "托盘窄条已关闭(仍用旧动→静)" + self._toast(tip, "ok") + self._set_result_text(tip, "ok") + + def _draw_strip(): + _apply_strip() + try: + dialog.destroy() + except Exception: + pass + self._settings_dialog = None + self.start_tray_strip_draw() + + def _capture_empty(): + _apply_strip() + ok = self.capture_tray_strip_baseline() + if ok: + roi2 = getattr(self, "_tray_strip_roi", roi) + roi_lbl.config( + text=f"当前 ROI:{roi2[0]:.2f},{roi2[1]:.2f},{roi2[2]:.2f},{roi2[3]:.2f}|已采空台" + ) + self._toast("空台基准已采集", "ok") + else: + self._toast("采空台失败:请先打开相机预览", "warn") + + strip_btns = tk.Frame(tab_cfg, bg=COLORS["bg"]) + strip_btns.pack(fill=tk.X, padx=18, pady=(10, 0)) + PillButton(strip_btns, text="应用窄条设置", variant="secondary", size="sm", + command=_apply_strip).pack(side=tk.LEFT, padx=(0, 8)) + PillButton(strip_btns, text="在预览上画窄条", variant="primary", size="sm", + command=_draw_strip).pack(side=tk.LEFT, padx=(0, 8)) + PillButton(strip_btns, text="采空台基准", variant="ghost", size="sm", + command=_capture_empty).pack(side=tk.LEFT) + + Divider(tab_cfg, bg=COLORS["bg"], padx=18, pady=14).pack(fill=tk.X) + tk.Label(tab_cfg, text="上报方式", bg=COLORS["bg"], fg=COLORS["text"], font=FONTS["h2"]).pack(anchor=tk.W, padx=18) tk.Label(tab_cfg, text="模拟模式把图片复制到本地对象存储 media_store,JSON 里填 URL;真实模式先 POST 图片再上报结果。", @@ -1227,34 +1341,50 @@ class BaseDishApp(PendingLearningUIMixin): hw = int(host.winfo_width() or 0) hh = int(host.winfo_height() or 0) if hw > 40 and hh > 40: - return max(hw - 4, 320), max(hh - 4, 240) + return max(hw - 2, 320), max(hh - 2, 240) panel = getattr(self, "preview_panel", None) if panel is not None: panel.update_idletasks() pw = int(panel.winfo_width() or 0) ph = int(panel.winfo_height() or 0) if pw > 40 and ph > 40: - return max(pw - 8, 320), max(ph - 8, 240) + return max(pw - 4, 320), max(ph - 4, 240) except Exception: pass return max_w, max_h - def _scale_preview_image(self, im): - """等比放大/缩小到铺满预览区(允许放大,避免画面偏小)。""" + def _scale_preview_image(self, im, fill=True): + """缩放到预览区。 + + fill=True(默认):铺满显示区(cover,可能略裁边)。 + fill=False:完整塞进显示区(contain,可能留边)。 + """ max_w, max_h = self._preview_target_size() iw, ih = im.size if iw <= 0 or ih <= 0: return im - scale = min(max_w / float(iw), max_h / float(ih)) + if fill: + scale = max(max_w / float(iw), max_h / float(ih)) + else: + scale = min(max_w / float(iw), max_h / float(ih)) nw = max(1, int(round(iw * scale))) nh = max(1, int(round(ih * scale))) - if (nw, nh) == (iw, ih): - return im try: - resample = Image.Resampling.LANCZOS + resample = Image.Resampling.BILINEAR except AttributeError: - resample = Image.LANCZOS - return im.resize((nw, nh), resample) + resample = Image.BILINEAR + if (nw, nh) != (iw, ih): + im = im.resize((nw, nh), resample) + if fill and (nw > max_w or nh > max_h): + left = max(0, (nw - max_w) // 2) + top = max(0, (nh - max_h) // 2) + im = im.crop((left, top, left + max_w, top + max_h)) + self._preview_crop_offset = (left, top) + self._preview_scaled_size = (nw, nh) + else: + self._preview_crop_offset = (0, 0) + self._preview_scaled_size = im.size + return im def show_preview_image(self, image_path): self.stop_loading() @@ -1458,6 +1588,9 @@ class BaseDishApp(PendingLearningUIMixin): justify=tk.CENTER, ) self.img_label.pack(fill=tk.BOTH, expand=True) + self.img_label.bind("", self._on_tray_strip_press) + self.img_label.bind("", self._on_tray_strip_drag) + self.img_label.bind("", self._on_tray_strip_release) # 视频回放进度(仅 get_video_progress 可用时显示) self._video_prog_row = tk.Frame(panel, bg=c["bg"]) @@ -2265,6 +2398,338 @@ class BaseDishApp(PendingLearningUIMixin): return self.start_live_scan() + @staticmethod + def _parse_tray_strip_roi(text): + try: + parts = [float(x.strip()) for x in str(text or "").split(",")] + if len(parts) != 4: + raise ValueError("need 4 floats") + x1, y1, x2, y2 = parts + x1, x2 = sorted((max(0.0, min(1.0, x1)), max(0.0, min(1.0, x2)))) + y1, y2 = sorted((max(0.0, min(1.0, y1)), max(0.0, min(1.0, y2)))) + if x2 - x1 < 0.02 or y2 - y1 < 0.005: + raise ValueError("roi too small") + return (x1, y1, x2, y2) + except Exception: + return (0.18, 0.70, 0.82, 0.78) + + def _load_tray_strip_config(self): + path = getattr(self, "_tray_strip_config_path", None) or DISH_TRAY_STRIP_CONFIG_PATH + try: + if not path or not os.path.isfile(path): + return + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + roi = data.get("roi") + if isinstance(roi, (list, tuple)) and len(roi) == 4: + self._tray_strip_roi = self._parse_tray_strip_roi(",".join(str(x) for x in roi)) + base = data.get("baseline") + if isinstance(base, list) and len(base) >= 16: + self._tray_strip_baseline = np.asarray(base, dtype=np.float32) + if "enabled" in data: + self._tray_strip_enabled = bool(data.get("enabled")) + if "change_thr" in data: + self._tray_strip_change_thr = float(data.get("change_thr")) + if "empty_sim" in data: + self._tray_strip_empty_sim = float(data.get("empty_sim")) + if "still_frames" in data: + self._tray_strip_still_frames = max(1, int(data.get("still_frames"))) + print(f"[tray] loaded strip config roi={self._tray_strip_roi}") + except Exception as exc: + print(f"[tray] load config failed: {exc}") + + def _save_tray_strip_config(self): + path = getattr(self, "_tray_strip_config_path", None) or DISH_TRAY_STRIP_CONFIG_PATH + try: + os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True) + payload = { + "enabled": bool(getattr(self, "_tray_strip_enabled", False)), + "roi": list(getattr(self, "_tray_strip_roi", (0.18, 0.70, 0.82, 0.78))), + "change_thr": float(getattr(self, "_tray_strip_change_thr", 0.10)), + "empty_sim": float(getattr(self, "_tray_strip_empty_sim", 0.93)), + "still_frames": int(getattr(self, "_tray_strip_still_frames", 4)), + } + base = getattr(self, "_tray_strip_baseline", None) + if base is not None: + payload["baseline"] = np.asarray(base, dtype=np.float32).reshape(-1).tolist() + with open(path, "w", encoding="utf-8") as f: + json.dump(payload, f, ensure_ascii=False, indent=2) + print(f"[tray] saved strip config -> {path}") + return True + except Exception as exc: + print(f"[tray] save config failed: {exc}") + return False + + def _tray_strip_xyxy(self, frame_bgr): + if frame_bgr is None: + return None + h, w = frame_bgr.shape[:2] + if h <= 1 or w <= 1: + return None + x1, y1, x2, y2 = getattr(self, "_tray_strip_roi", (0.18, 0.70, 0.82, 0.78)) + xa = int(round(x1 * w)) + xb = int(round(x2 * w)) + ya = int(round(y1 * h)) + yb = int(round(y2 * h)) + xa, xb = max(0, min(xa, w - 1)), max(1, min(xb, w)) + ya, yb = max(0, min(ya, h - 1)), max(1, min(yb, h)) + if xb <= xa + 2 or yb <= ya + 1: + return None + return xa, ya, xb, yb + + def _tray_strip_fingerprint(self, frame_bgr): + """窄条灰度指纹,用于空台对比 / 到位后静止判定。""" + box = self._tray_strip_xyxy(frame_bgr) + if box is None: + return None + xa, ya, xb, yb = box + try: + crop = frame_bgr[ya:yb, xa:xb] + if crop is None or crop.size == 0: + return None + gray = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY) if crop.ndim == 3 else crop + small = cv2.resize(gray, (96, 16), interpolation=cv2.INTER_AREA) + small = cv2.GaussianBlur(small, (3, 3), 0) + v = small.astype(np.float32).ravel() + v -= float(v.mean()) + n = float(np.linalg.norm(v)) + if n < 1e-6: + return None + return v / n + except Exception: + return None + + def _tray_strip_change_from_empty(self, fp): + base = getattr(self, "_tray_strip_baseline", None) + if fp is None or base is None: + return None + similar, sim = self._frames_very_similar(fp, base, threshold=0.0) + if sim is None: + return None + return max(0.0, 1.0 - float(sim)) + + def _tray_strip_is_empty(self, fp): + base = getattr(self, "_tray_strip_baseline", None) + if fp is None or base is None: + return False + thr = float(getattr(self, "_tray_strip_empty_sim", DISH_TRAY_STRIP_EMPTY_SIM) or 0.93) + similar, _ = self._frames_very_similar(fp, base, threshold=thr) + return bool(similar) + + def _ensure_tray_strip_baseline(self, frame_bgr): + if getattr(self, "_tray_strip_baseline", None) is not None: + return True + fp = self._tray_strip_fingerprint(frame_bgr) + if fp is None: + return False + self._tray_strip_baseline = fp + self._save_tray_strip_config() + print("[tray] auto captured empty baseline") + return True + + def capture_tray_strip_baseline(self, frame_bgr=None): + """手动采空台基准(结算台无托盘时点)。""" + if frame_bgr is None: + with self._live_lock: + frame_bgr = None if self._latest_frame is None else self._latest_frame.copy() + if frame_bgr is None: + frame_bgr = self.grab_preview_frame() if hasattr(self, "grab_preview_frame") else None + if frame_bgr is None: + return False + fp = self._tray_strip_fingerprint(frame_bgr) + if fp is None: + return False + self._tray_strip_baseline = fp + self._tray_strip_state = "empty_wait" + self._tray_strip_occupied = False + self._tray_strip_still_count = 0 + ok = self._save_tray_strip_config() + print("[tray] baseline captured manually") + return ok + + def _reset_tray_strip_gate(self): + self._tray_strip_state = "empty_wait" + self._tray_strip_prev_fp = None + self._tray_strip_settle_fp = None + self._tray_strip_still_count = 0 + self._tray_strip_occupied = False + + def _clear_live_preview_boxes(self): + """清掉预览叠加框(左侧已点清单不改)。""" + if getattr(self, "_live_overlay_dishes", None): + self._live_overlay_dishes = [] + + def _update_tray_strip_gate(self, frame_bgr): + """窄条门闩:空台变化大=到位 → 静止满帧触发识别 → 恢复空台解锁。""" + if frame_bgr is None: + return False + if not self._ensure_tray_strip_baseline(frame_bgr): + return False + fp = self._tray_strip_fingerprint(frame_bgr) + if fp is None: + return False + + change = self._tray_strip_change_from_empty(fp) + thr = float(getattr(self, "_tray_strip_change_thr", DISH_TRAY_STRIP_CHANGE_THR) or 0.10) + occupied = (change is not None and change >= thr) + self._tray_strip_occupied = occupied + need = max(1, int(getattr(self, "_tray_strip_still_frames", DISH_TRAY_STRIP_STILL_FRAMES) or 4)) + state = getattr(self, "_tray_strip_state", "empty_wait") + + # 识别后:等窄条恢复空台再允许下一单;托盘离开窄条后立刻清掉预览框 + if state == "wait_clear": + if not occupied: + self._clear_live_preview_boxes() + if self._tray_strip_is_empty(fp): + self._clear_live_preview_boxes() + self._reset_tray_strip_gate() + try: + self._reset_order_gate() + except Exception: + pass + print("[tray] strip cleared → unlock next order") + return False + + if state == "empty_wait": + if not occupied: + self._tray_strip_prev_fp = fp + return False + # 托盘进入:开始等放稳 + self._tray_strip_state = "occupied_settle" + self._tray_strip_settle_fp = fp + self._tray_strip_prev_fp = fp + self._tray_strip_still_count = 1 + print(f"[tray] strip occupied change={change:.3f} thr={thr:.3f},等待放稳") + return False + + if state == "occupied_settle": + if not occupied: + # 误触发或托盘又撤走 + self._reset_tray_strip_gate() + return False + similar, _ = self._frames_very_similar( + fp, self._tray_strip_settle_fp, threshold=self._frame_sim_threshold, + ) + if not similar: + self._tray_strip_settle_fp = fp + self._tray_strip_still_count = 1 + self._tray_strip_prev_fp = fp + return False + self._tray_strip_still_count = int(getattr(self, "_tray_strip_still_count", 0) or 0) + 1 + self._tray_strip_settle_fp = fp + if self._tray_strip_still_count >= need: + self._tray_strip_state = "wait_clear" + self._tray_strip_still_count = 0 + self._yolo_trigger_pending = True + # 同步旧状态字段,便于 UI tip 复用 + self._motion_state = "wait_motion" + print(f"[tray] strip still {need} frames → trigger YOLO") + return True + return False + + return False + + def start_tray_strip_draw(self): + """进入预览拖拽画窄条模式。""" + self._tray_strip_draw_mode = True + self._tray_strip_drag_start = None + self._tray_strip_drag_end = None + self._tray_strip_show_overlay = True + self._toast("请在预览上拖拽画出托盘到位窄条,松手即保存", "info") + self._set_result_text("标定中:在预览画面拖拽画出又长又窄的区域", "busy") + + def _on_tray_strip_press(self, event): + if not getattr(self, "_tray_strip_draw_mode", False): + return + self._tray_strip_drag_start = (event.x, event.y) + self._tray_strip_drag_end = (event.x, event.y) + + def _on_tray_strip_drag(self, event): + if not getattr(self, "_tray_strip_draw_mode", False): + return + if self._tray_strip_drag_start is None: + return + self._tray_strip_drag_end = (event.x, event.y) + + def _on_tray_strip_release(self, event): + if not getattr(self, "_tray_strip_draw_mode", False): + return + start = getattr(self, "_tray_strip_drag_start", None) + end = (event.x, event.y) + self._tray_strip_drag_start = None + self._tray_strip_drag_end = None + self._tray_strip_draw_mode = False + if not start: + return + scaled = getattr(self, "_preview_scaled_size", None) or getattr(self, "_preview_disp_size", None) + crop = getattr(self, "_preview_crop_offset", (0, 0)) or (0, 0) + if not scaled or scaled[0] <= 1 or scaled[1] <= 1: + self._toast("预览尺寸无效,请先打开相机预览再画", "warn") + return + sw, sh = scaled + cx, cy = float(crop[0]), float(crop[1]) + # 显示区坐标 → 放大整帧坐标 → 归一化 + x1 = max(0.0, min(1.0, (min(start[0], end[0]) + cx) / float(sw))) + x2 = max(0.0, min(1.0, (max(start[0], end[0]) + cx) / float(sw))) + y1 = max(0.0, min(1.0, (min(start[1], end[1]) + cy) / float(sh))) + y2 = max(0.0, min(1.0, (max(start[1], end[1]) + cy) / float(sh))) + if x2 - x1 < 0.05 or y2 - y1 < 0.008: + self._toast("窄条太小,请重新拖拽画一条更长的区域", "warn") + self._tray_strip_draw_mode = True + return + self._tray_strip_roi = (x1, y1, x2, y2) + self._tray_strip_enabled = True + self._reset_tray_strip_gate() + # 画完后立刻用当前帧当空台(请保证画面无托盘) + self.capture_tray_strip_baseline() + self._save_tray_strip_config() + self._toast(f"窄条已保存 [{x1:.2f},{y1:.2f},{x2:.2f},{y2:.2f}]", "ok") + self._set_result_text("托盘窄条已标定;请保持空台后开始扫描", "ok") + + def _draw_tray_strip_overlay(self, pil_im, display_is_full_scaled=False): + """在预览图上画窄条 ROI(含拖拽中的临时框)。 + + display_is_full_scaled=True:当前图是 cover 放大后的整帧(裁切前),ROI 直接乘宽高。 + False:当前图已是裁切后的显示图,拖拽框用显示坐标;已保存 ROI 需扣掉 crop。 + """ + if pil_im is None: + return pil_im + show = bool(getattr(self, "_tray_strip_show_overlay", False)) + drawing = bool(getattr(self, "_tray_strip_draw_mode", False)) + if not show and not drawing and not getattr(self, "_tray_strip_enabled", False): + return pil_im + if not show and not drawing: + return pil_im + from PIL import ImageDraw + im = pil_im.convert("RGBA") + overlay = Image.new("RGBA", im.size, (0, 0, 0, 0)) + draw = ImageDraw.Draw(overlay) + w, h = im.size + # 拖拽中:事件坐标在裁切后的显示区上 + if drawing and self._tray_strip_drag_start and self._tray_strip_drag_end: + x1, y1 = self._tray_strip_drag_start + x2, y2 = self._tray_strip_drag_end + if display_is_full_scaled: + cx, cy = getattr(self, "_preview_crop_offset", (0, 0)) or (0, 0) + x1, x2 = x1 + cx, x2 + cx + y1, y2 = y1 + cy, y2 + cy + draw.rectangle( + [min(x1, x2), min(y1, y2), max(x1, x2), max(y1, y2)], + outline=(255, 80, 0, 230), width=3, + ) + elif getattr(self, "_tray_strip_enabled", False) or show: + rx1, ry1, rx2, ry2 = getattr(self, "_tray_strip_roi", (0.18, 0.70, 0.82, 0.78)) + if display_is_full_scaled: + box = [rx1 * w, ry1 * h, rx2 * w, ry2 * h] + else: + sw, sh = getattr(self, "_preview_scaled_size", (w, h)) or (w, h) + cx, cy = getattr(self, "_preview_crop_offset", (0, 0)) or (0, 0) + box = [rx1 * sw - cx, ry1 * sh - cy, rx2 * sw - cx, ry2 * sh - cy] + occupied = bool(getattr(self, "_tray_strip_occupied", False)) + color = (255, 140, 0, 220) if occupied else (46, 204, 113, 220) + draw.rectangle(box, outline=color, width=3) + return Image.alpha_composite(im, overlay).convert(pil_im.mode) + def _reset_motion_gate(self, initial_state="moving"): """重置动→静触发状态机。默认 moving:开扫后连续静止帧满也会识别一次。""" self._motion_state = initial_state @@ -2272,6 +2737,7 @@ class BaseDishApp(PendingLearningUIMixin): self._motion_settle_fp = None self._motion_still_count = 0 self._yolo_trigger_pending = False + self._reset_tray_strip_gate() def _motion_fingerprint(self, frame_bgr): """只对菜品/餐盘区域提指纹,忽略旁边行人晃动。""" @@ -2281,21 +2747,21 @@ class BaseDishApp(PendingLearningUIMixin): ) def _still_hold_frames(self): + if getattr(self, "_tray_strip_enabled", False): + return max(1, int(getattr(self, "_tray_strip_still_frames", DISH_TRAY_STRIP_STILL_FRAMES) or 4)) return max(1, int(getattr(self, "_live_still_hold_frames", DISH_LIVE_STILL_HOLD_FRAMES) or 4)) def _update_motion_gate(self, frame_bgr): """根据当前帧更新运动状态;连续 N 帧与静止锚点相似时触发 YOLO 一次。 - 运动只看餐盘区域(有上次检测框用框,否则画面中央),避免旁人晃动误触发。 - 用帧计数而非秒数,避免轮询抖动 / 帧率波动导致触发不稳。 - - 状态: - moving — 菜品区域在动(或刚开扫) - settling — 菜品刚停住,累计连续相似帧 - wait_motion — 已识别过,菜品静止中忽略,直到菜品再出现运动 + 开启托盘窄条时:只用窄条颜色变化做到位门闩 + 静止触发 + 清场解锁。 + 未开启时:沿用原「菜品区域动→静」逻辑。 """ if frame_bgr is None: return False + if getattr(self, "_tray_strip_enabled", False): + return self._update_tray_strip_gate(frame_bgr) + fp = self._motion_fingerprint(frame_bgr) if fp is None: return False @@ -2501,17 +2967,18 @@ class BaseDishApp(PendingLearningUIMixin): still_n = self._still_hold_frames() hold_s = float(getattr(self, "_live_stable_seconds", DISH_LIVE_PENDING_STABLE_SECONDS) or 1.0) hold_txt = f"{hold_s:g}" + if getattr(self, "_tray_strip_enabled", False): + tip = f"实时扫描中 · 窄条到位后静止 {still_n} 帧识别 · 未知约 {hold_txt}s 记待学习" + msg = f"实时扫描已开启(托盘窄条到位→放稳 {still_n} 帧识别;未知约 {hold_txt}s 写入待学习)" + else: + tip = f"实时扫描中 · 动→静连续 {still_n} 帧识别一次 · 未知约 {hold_txt}s 记待学习" + msg = f"实时扫描已开启(动→静连续 {still_n} 帧识别一次;未知约 {hold_txt}s 写入待学习)" if hasattr(self, "scan_tip_label"): try: - self.scan_tip_label.config( - text=f"实时扫描中 · 动→静连续 {still_n} 帧识别一次 · 未知约 {hold_txt}s 记待学习" - ) + self.scan_tip_label.config(text=tip) except Exception: pass - self._set_result_text( - f"实时扫描已开启(动→静连续 {still_n} 帧识别一次;未知约 {hold_txt}s 写入待学习)", - "ok", - ) + self._set_result_text(msg, "ok") self._schedule_live_tick(immediate=True) def _schedule_preview_tick(self, immediate=False): @@ -2571,13 +3038,30 @@ class BaseDishApp(PendingLearningUIMixin): rgb = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB) im = Image.fromarray(rgb) iw, ih = im.size - im = self._scale_preview_image(im) - nw, nh = im.size + self._preview_src_size = (iw, ih) + max_w, max_h = self._preview_target_size() + # 先按 cover 放大到能铺满,画框后再裁切,避免留白 + scale = max(max_w / float(iw), max_h / float(ih)) if iw > 0 and ih > 0 else 1.0 + nw = max(1, int(round(iw * scale))) + nh = max(1, int(round(ih * scale))) + try: + resample = Image.Resampling.BILINEAR + except AttributeError: + resample = Image.BILINEAR + if (nw, nh) != (iw, ih): + im = im.resize((nw, nh), resample) + left = max(0, (nw - max_w) // 2) + top = max(0, (nh - max_h) // 2) + self._preview_crop_offset = (left, top) + self._preview_scaled_size = (nw, nh) overlay = dishes if dishes is not None else getattr(self, "_live_overlay_dishes", None) if overlay and iw > 0 and ih > 0: - sx = nw / float(iw) - sy = nh / float(ih) - im = self._draw_live_overlay_pil(im, overlay, sx, sy) + im = self._draw_live_overlay_pil(im, overlay, nw / float(iw), nh / float(ih)) + # 窄条画在放大整帧上,再裁切,保证铺满且框位置正确 + im = self._draw_tray_strip_overlay(im, display_is_full_scaled=True) + if nw > max_w or nh > max_h: + im = im.crop((left, top, left + max_w, top + max_h)) + self._preview_disp_size = im.size photo = ImageTk.PhotoImage(im) self.img_label.config(text="", image=photo, bg=COLORS["preview_mid"], fg=COLORS["preview_fg"]) self.img_label.image = photo @@ -2813,6 +3297,58 @@ class BaseDishApp(PendingLearningUIMixin): fired = True if not fired: + if getattr(self, "_tray_strip_enabled", False): + st = getattr(self, "_tray_strip_state", "empty_wait") + if st == "wait_clear": + # 托盘已离开窄条:不要再叠旧框;仍在台上则继续推进未知菜稳定计时 + if not getattr(self, "_tray_strip_occupied", False): + self._clear_live_preview_boxes() + else: + self._live_on_similar_frame_skip() + + def _clear_tip(occupied=bool(getattr(self, "_tray_strip_occupied", False))): + if hasattr(self, "scan_tip_label"): + try: + if occupied: + self.scan_tip_label.config( + text="扫描中 · 已识别 · 请拿走托盘(窄条恢复空台后解锁下一单)" + ) + else: + self.scan_tip_label.config( + text="扫描中 · 等待窄条恢复空台后解锁下一单" + ) + except Exception: + pass + + self.root.after(0, _clear_tip) + elif st == "occupied_settle": + need = self._still_hold_frames() + got = int(getattr(self, "_tray_strip_still_count", 0) or 0) + left = max(0, need - got) + + def _settle_tip(remain=left, total=need, cur=got): + if hasattr(self, "scan_tip_label"): + try: + self.scan_tip_label.config( + text=f"扫描中 · 托盘到位,放稳 {cur}/{total} 帧后识别(还需 {remain})" + ) + except Exception: + pass + + self.root.after(0, _settle_tip) + else: + def _empty_tip(): + if hasattr(self, "scan_tip_label"): + try: + self.scan_tip_label.config( + text="扫描中 · 等待托盘压住窄条区域" + ) + except Exception: + pass + + self.root.after(0, _empty_tip) + return + if getattr(self, "_motion_state", None) == "wait_motion": self._live_on_similar_frame_skip() elif getattr(self, "_motion_state", None) == "settling": @@ -2856,17 +3392,22 @@ class BaseDishApp(PendingLearningUIMixin): cropped_file_paths = [c.get("path") for c in crop_items] crop_arrays = [c.get("image") for c in crop_items] - # 识别完成后进入 wait_motion:只盯餐盘区域,旁人晃动不触发 + # 识别完成后:窄条模式等清场;旧模式等菜品区域再运动 self._last_motion_boxes = list(boxes or []) if crop_items else None self._last_detect_fp = self._motion_fingerprint(frame) self._motion_state = "wait_motion" + if getattr(self, "_tray_strip_enabled", False): + self._tray_strip_state = "wait_clear" self._yolo_trigger_pending = False if not crop_items: self._last_live_dishes = [] self._last_motion_boxes = None self._reset_live_stability() - unlocked = self._note_order_scene_clear() + # 窄条模式:无盘也不立刻解锁,等窄条恢复空台 + unlocked = False + if not getattr(self, "_tray_strip_enabled", False): + unlocked = self._note_order_scene_clear() def _no_plate(did_unlock=unlocked): self._live_overlay_dishes = [] @@ -2877,10 +3418,14 @@ class BaseDishApp(PendingLearningUIMixin): self._set_result_text(tip, "warn") if hasattr(self, "scan_tip_label"): try: - self.scan_tip_label.config( - text="扫描中 · 未检出餐盘 · 左侧保持上次已点 · 等待下一次运动" - + (" · 已解锁下一单" if did_unlock else "") - ) + if getattr(self, "_tray_strip_enabled", False): + tip_scan = "扫描中 · 未检出餐盘 · 左侧保持上次已点 · 请拿走托盘后解锁" + else: + tip_scan = ( + "扫描中 · 未检出餐盘 · 左侧保持上次已点 · 等待下一次运动" + + (" · 已解锁下一单" if did_unlock else "") + ) + self.scan_tip_label.config(text=tip_scan) except Exception: pass @@ -2964,7 +3509,7 @@ class BaseDishApp(PendingLearningUIMixin): same_order=order_unchanged, frame_ref=frame, ): - # 只更新叠加框 + 左侧清单;预览继续刷实时帧 + # 预览叠加识别框;窄条模式下托盘拿走后再清框(见 wait_clear) self._live_overlay_dishes = list(ds or []) # 左侧展示识别标注图(含未知),勿用原图 self.render_result_dock(ds, path, frame_bgr=frame_ref)