窄位带更新

This commit is contained in:
LiXin01 2026-10-16 19:56:32 +08:00
parent 57b8a035b8
commit ec77ec9b69
2 changed files with 600 additions and 36 deletions

View File

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

View File

@ -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
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("<ButtonPress-1>", self._on_tray_strip_press)
self.img_label.bind("<B1-Motion>", self._on_tray_strip_drag)
self.img_label.bind("<ButtonRelease-1>", 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,16 +3392,21 @@ 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 = False
if not getattr(self, "_tray_strip_enabled", False):
unlocked = self._note_order_scene_clear()
def _no_plate(did_unlock=unlocked):
@ -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 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)