「手」「空盘」直接丢弃,不裁剪、不识别、不上报,

同一盘在约 1.5 秒内再次识别,即使个别菜名不同,只要检测框布局相近,就不再重复上传
This commit is contained in:
wanghanbo 2026-10-14 15:09:14 +08:00
parent 73aeba87a2
commit 678706454a
8 changed files with 437 additions and 103 deletions

View File

@ -330,6 +330,8 @@ class PartTemplateManager:
add_time=add_time, add_time=add_time,
) )
self.template_library[part_type].append(record) self.template_library[part_type].append(record)
# 学菜成功:上传到云端 template/{菜名}/
self._upload_template_cloud_async(part_type, template_img_path)
added += 1 added += 1
if added: if added:
self._rebuild_sim_index() self._rebuild_sim_index()
@ -337,6 +339,15 @@ class PartTemplateManager:
self._notify_learned_async(part_type) self._notify_learned_async(part_type)
return added, len(image_paths) 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 @staticmethod
def _notify_learned_async(dish_name): def _notify_learned_async(dish_name):
name = (dish_name or '').strip() name = (dish_name or '').strip()
@ -400,10 +411,14 @@ class PartTemplateManager:
if prepared: if prepared:
self.repo.insert_templates_batch(prepared) self.repo.insert_templates_batch(prepared)
self.load_templates() self.load_templates()
# 批量导入:对成功写入的菜名去重后上报「已学习」 # 批量导入:对成功写入的菜名去重后上报「已学习」+ 上传云端模板目录
learned_names = sorted({item['category_name'] for item in prepared if item.get('category_name')}) learned_names = sorted({item['category_name'] for item in prepared if item.get('category_name')})
for name in learned_names: for name in learned_names:
self._notify_learned_async(name) 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 return len(prepared), failures
def delete_template(self, part_type, template_index): def delete_template(self, part_type, template_index):

View File

@ -30,20 +30,44 @@ class Config:
GENERATE_VISUALIZATION = True # 是否生成可视化图 GENERATE_VISUALIZATION = True # 是否生成可视化图
# 只有 Plate 一个类别 # 默认类别名(加载权重后会被 model.names 覆盖)
LABEL_NAMES = {0: "Plate"} LABEL_NAMES = {0: "Plate"}
COLORS = {'Plate': (0, 255, 0)} 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: class YOLOv11PlateExtractor:
def __init__(self, config): def __init__(self, config):
self.config = config self.config = config
weights = resolve_yolo_weights(config.MODEL_PATH) weights = resolve_yolo_weights(config.MODEL_PATH)
self.model = YOLO(weights) self.model = YOLO(weights)
self.weights_path = 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"✅ 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): def predict(self, source):
"""source 可为图片路径或 BGR ndarray。""" """source 可为图片路径或 BGR ndarray。"""
@ -55,14 +79,43 @@ class YOLOv11PlateExtractor:
verbose=False, verbose=False,
)[0] )[0]
def crop_plate(self, image, results): def _class_name(self, results, cls_id):
cropped_regions = [] cid = int(cls_id)
if results.boxes is not None: 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() boxes = results.boxes.xyxy.cpu().numpy()
confs = results.boxes.conf.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 = []
h, w = image.shape[:2] h, w = image.shape[:2]
for box, conf in zip(boxes, confs): for box, conf, cname in self.iter_kept_detections(results):
x1, y1, x2, y2 = map(int, box) x1, y1, x2, y2 = map(int, box)
x1 = max(0, x1) x1 = max(0, x1)
y1 = max(0, y1) y1 = max(0, y1)
@ -75,6 +128,7 @@ class YOLOv11PlateExtractor:
"image": crop.copy(), "image": crop.copy(),
"conf": float(conf), "conf": float(conf),
"box": [x1, y1, x2, y2], "box": [x1, y1, x2, y2],
"class_name": cname,
}) })
return cropped_regions return cropped_regions
@ -138,20 +192,17 @@ class YOLOv11PlateExtractor:
def visualize_results(self, image, results, save_path, labels=None): def visualize_results(self, image, results, save_path, labels=None):
""" """
在检测框上绘制标签。 在检测框上绘制标签(已过滤手/空盘)。
labels 为 None 时画 Plate+置信度;传入列表时画菜品名称(认菜不认盘展示)。 labels 为 None 时画 类别+置信度;传入列表时画菜品名称(认菜不认盘展示)。
""" """
img = Image.fromarray(cv2.cvtColor(image, cv2.COLOR_BGR2RGB)) img = Image.fromarray(cv2.cvtColor(image, cv2.COLOR_BGR2RGB))
font = self._load_font() font = self._load_font()
kept = list(self.iter_kept_detections(results))
if results.boxes is not None: for i, (box, conf, cname) in enumerate(kept):
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): if labels is not None and i < len(labels):
text = str(labels[i]) text = str(labels[i])
else: else:
text = f"Plate {conf:.2f}" text = f"{cname} {conf:.2f}"
img = self._draw_box_and_chip(img, box, text, font) img = self._draw_box_and_chip(img, box, text, font)
os.makedirs(os.path.dirname(save_path) or ".", exist_ok=True) os.makedirs(os.path.dirname(save_path) or ".", exist_ok=True)
img.save(save_path) img.save(save_path)
@ -246,6 +297,7 @@ class YOLOv11PlateExtractor:
"image": img_crop, "image": img_crop,
"path": path, "path": path,
"conf": region.get("conf"), "conf": region.get("conf"),
"class_name": region.get("class_name"),
}) })
boxes.append(region["box"]) boxes.append(region["box"])

View File

@ -164,60 +164,60 @@ class MealScheduleStore:
return dish.get('price') return dish.get('price')
return None 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): def current_period(self, now=None):
"""
根据当前时间匹配餐段。
返回 dict: mealPeriodId/name/startTime/endTime;不在任何餐段则 None。
"""
now = now or datetime.now() 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 minutes = now.hour * 60 + now.minute
# 写死餐段(本地优先) # 优先用 bases[].meals 的时段(与菜品绑定);否则用顶层 mealPeriods
HARD_PERIODS = [ periods = []
{'mealPeriodId': 1, 'mealPeriodName': '早餐', 'startTime': '07:00', 'endTime': '09:00'}, for base in raw.get('bases') or []:
{'mealPeriodId': 2, 'mealPeriodName': '中餐', 'startTime': '12:30', 'endTime': '19:30'}, # 可自行改 for meal in base.get('meals') or []:
{'mealPeriodId': 3, 'mealPeriodName': '晚餐', 'startTime': '19:00', 'endTime': '20:00'}, periods.append({
] 'mealPeriodId': meal.get('mealPeriodId'),
for p in HARD_PERIODS: 'mealPeriodName': meal.get('mealPeriodName') or meal.get('name'),
if _in_period(minutes, p['startTime'], p['endTime']): '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 p
return None 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): def current_period_dish_names(self, now=None):
"""当前餐段菜名集合(小模版库)。不在餐段 / 无排餐 → 空集。""" """当前餐段菜名集合(小模版库)。不在餐段 / 无排餐 → 空集。"""
now = now or datetime.now() now = now or datetime.now()

View File

@ -96,7 +96,7 @@ DISH_STAPLE_NAMES = [
if x.strip() if x.strip()
] ]
DISH_STAPLE_LIB_THRESHOLD = float(os.environ.get('DISH_STAPLE_LIB_THRESHOLD', '0.80')) 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 则优先认主食(减轻主食被菜品抢走) # 主食与餐段菜都达标时:主食分 ≥ 菜分 - margin 则优先认主食(减轻主食被菜品抢走)
DISH_STAPLE_PRIORITY_MARGIN = float(os.environ.get('DISH_STAPLE_PRIORITY_MARGIN', '0.03')) DISH_STAPLE_PRIORITY_MARGIN = float(os.environ.get('DISH_STAPLE_PRIORITY_MARGIN', '0.03'))
# 每餐开始前多少分钟拉取排餐(默认 60 = 开餐前 1 小时) # 每餐开始前多少分钟拉取排餐(默认 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_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_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 相机,不受此项影响。 # 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, 'DISH_STREAM_RECORD_ENABLED', _STREAM_RECORD_DEFAULT,
) == '1' ) == '1'
DISH_STREAM_RECORD_DIR = os.environ.get('DISH_STREAM_RECORD_DIR', 'videos') 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 分钟一切,便于清理 # 单段时长(秒),默认 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_RECORD_FPS = float(os.environ.get('DISH_STREAM_RECORD_FPS', '10'))
DISH_STREAM_MAX_WIDTH = int(os.environ.get('DISH_STREAM_MAX_WIDTH', '1280')) DISH_STREAM_MAX_WIDTH = int(os.environ.get('DISH_STREAM_MAX_WIDTH', '1280'))

View File

@ -281,14 +281,32 @@ class ApiHandler(BaseHTTPRequestHandler):
filename, data = blob filename, data = blob
media_type = (fields.get("type") or "source").strip().lower() media_type = (fields.get("type") or "source").strip().lower()
if media_type not in ALLOWED_TYPES: 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) self._send(status, out)
return return
base_id = fields.get("base_id") or self.headers.get("X-Base-Code") or "BASE" base_id = fields.get("base_id") or self.headers.get("X-Base-Code") or "BASE"
request_id = fields.get("request_id") or "" 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: try:
saved = _store.save_bytes( 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: except Exception as exc:
status, out = _err(500, 50000, str(exc)) status, out = _err(500, 50000, str(exc))

View File

@ -30,7 +30,8 @@ MEDIA_ROOT = _DEFAULT_MEDIA_ROOT
MEDIA_PUBLIC_BASE = os.environ.get("DISH_MEDIA_PUBLIC_BASE", CLOUD_MEDIA_PUBLIC_BASE).rstrip("/") MEDIA_PUBLIC_BASE = os.environ.get("DISH_MEDIA_PUBLIC_BASE", CLOUD_MEDIA_PUBLIC_BASE).rstrip("/")
_SAFE = re.compile(r"[^A-Za-z0-9._-]+") _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"} ALLOWED_EXT = {".jpg", ".jpeg", ".png", ".bmp", ".webp"}
@ -39,6 +40,15 @@ def _safe_token(value, fallback="x"):
return text or fallback 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): def public_url(rel_path):
rel = str(rel_path or "").replace("\\", "/").lstrip("/") rel = str(rel_path or "").replace("\\", "/").lstrip("/")
return f"{MEDIA_PUBLIC_BASE}/{rel}" return f"{MEDIA_PUBLIC_BASE}/{rel}"
@ -62,7 +72,15 @@ class MediaStore:
self.public_base = (public_base or MEDIA_PUBLIC_BASE).rstrip("/") self.public_base = (public_base or MEDIA_PUBLIC_BASE).rstrip("/")
os.makedirs(self.root, exist_ok=True) 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: if not data:
raise ValueError("empty file") raise ValueError("empty file")
media_type = (media_type or "source").strip().lower() media_type = (media_type or "source").strip().lower()
@ -71,10 +89,22 @@ class MediaStore:
ext = Path(filename or "").suffix.lower() or ".jpg" ext = Path(filename or "").suffix.lower() or ".jpg"
if ext not in ALLOWED_EXT: if ext not in ALLOWED_EXT:
ext = ".jpg" ext = ".jpg"
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)}") req = _safe_token(request_id or f"REQ_{int(time.time() * 1000)}")
base = _safe_token(base_id or "BASE") base = _safe_token(base_id or "BASE")
stem = _safe_token(Path(filename or media_type).stem, media_type) stem = _safe_token(Path(filename or media_type).stem, media_type)
rel = f"{base}/{req}/{media_type}_{stem}{ext}" rel = f"{base}/{req}/{media_type}_{stem}{ext}"
dest = local_path_under(self.root, rel) dest = local_path_under(self.root, rel)
os.makedirs(os.path.dirname(dest), exist_ok=True) os.makedirs(os.path.dirname(dest), exist_ok=True)
with open(dest, "wb") as fh: with open(dest, "wb") as fh:
@ -87,7 +117,14 @@ class MediaStore:
"type": media_type, "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): if not src_path or not os.path.isfile(src_path):
return None return None
with open(src_path, "rb") as fh: with open(src_path, "rb") as fh:
@ -98,6 +135,7 @@ class MediaStore:
media_type=media_type, media_type=media_type,
base_id=base_id, base_id=base_id,
request_id=request_id, request_id=request_id,
dish_name=dish_name,
) )

View File

@ -114,6 +114,24 @@ class ImageClassifierDemo(BaseDishApp):
print(f"海康取帧失败: {e}") print(f"海康取帧失败: {e}")
return None 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): def take_live_frame(self):
"""识别用:优先复用预览缓存帧;不落盘(有订单时由 _save_order_image 写入)。""" """识别用:优先复用预览缓存帧;不落盘(有订单时由 _save_order_image 写入)。"""
frame = None frame = None

View File

@ -51,6 +51,9 @@ from dao.settings import (
DISH_LIVE_MOTION_SIM_WITH_ORDER, DISH_LIVE_MOTION_SIM_WITH_ORDER,
DISH_LIVE_ORDER_DEBOUNCE_SECONDS, DISH_LIVE_ORDER_DEBOUNCE_SECONDS,
DISH_LIVE_ORDER_CLEAR_HOLD_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_ENABLED,
DISH_STREAM_RECORD_DIR, DISH_STREAM_RECORD_DIR,
DISH_STREAM_RETENTION_MINUTES, DISH_STREAM_RETENTION_MINUTES,
@ -154,9 +157,13 @@ class BaseDishApp(PendingLearningUIMixin):
self._live_stable_snapshot = None # 稳定期间保留的 dishes,供写入 self._live_stable_snapshot = None # 稳定期间保留的 dishes,供写入
self._last_reported_sig = None # 实时上报去重:相同菜名集合只报一次 self._last_reported_sig = None # 实时上报去重:相同菜名集合只报一次
self._last_order_dish_sig = None # 仅菜名签名:菜未变不重复存图/统计 self._last_order_dish_sig = None # 仅菜名签名:菜未变不重复存图/统计
self._last_order_boxes = None # 上一单检测框,用于短时同场景去重(忽略菜名抖动)
self._last_order_at = 0.0 self._last_order_at = 0.0
self._order_clear_since = None # 无盘连续计时起点 self._order_clear_since = None # 无盘连续计时起点
self._order_clear_hold = float(DISH_LIVE_ORDER_CLEAR_HOLD_SECONDS) 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_detect_fp = None # 识别后锚点指纹(静止等待运动;仅餐盘区域)
self._last_live_dishes = None # 上一轮识别结果(跳过检测时推进稳定计时) self._last_live_dishes = None # 上一轮识别结果(跳过检测时推进稳定计时)
self._last_motion_boxes = None # 上一轮餐盘框,运动判定只看菜品区域 self._last_motion_boxes = None # 上一轮餐盘框,运动判定只看菜品区域
@ -1458,11 +1465,97 @@ class BaseDishApp(PendingLearningUIMixin):
justify=tk.CENTER, justify=tk.CENTER,
) )
self.img_label.pack(fill=tk.BOTH, expand=True) 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("<ButtonPress-1>", self._on_video_progress_press)
self._video_prog_scale.bind("<ButtonRelease-1>", self._on_video_progress_release)
self._video_prog_row.grid_remove()
self._loading = False self._loading = False
self._loading_job = None self._loading_job = None
self._loading_frame = 0 self._loading_frame = 0
return panel 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): def _build_pos_bottom(self, c):
bar = tk.Frame(self.root, bg=c["pos_bottom_bar"], height=SIZES.get("pos_bottom_h", 72)) 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) bar.pack(side=tk.BOTTOM, fill=tk.X)
@ -1958,6 +2051,7 @@ class BaseDishApp(PendingLearningUIMixin):
"""重置订单防重门闩(清场解锁 / 清空清单 / 启停扫描)。""" """重置订单防重门闩(清场解锁 / 清空清单 / 启停扫描)。"""
self._order_clear_since = None self._order_clear_since = None
self._last_order_dish_sig = None self._last_order_dish_sig = None
self._last_order_boxes = None
self._last_order_at = 0.0 self._last_order_at = 0.0
self._last_reported_sig = None self._last_reported_sig = None
@ -1982,26 +2076,107 @@ class BaseDishApp(PendingLearningUIMixin):
print(f"[order] 清场,已点清单可被下一盘覆盖(无盘 ≥ {hold:g}s)") print(f"[order] 清场,已点清单可被下一盘覆盖(无盘 ≥ {hold:g}s)")
return True return True
def _should_record_new_order(self, dishes): @staticmethod
"""菜品未变 → 不存图、不重新统计订单(含 is_unknown 未知菜)。 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` 已被清空,相同菜名可再报一单。 清场解锁后 `_last_order_dish_sig` 已被清空,相同菜名可再报一单。
""" """
sig = self._order_dish_signature(dishes) sig = self._order_dish_signature(dishes)
if not sig: if not sig:
return False, sig return False, sig
last_sig = getattr(self, "_last_order_dish_sig", None) 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: if sig == last_sig:
debounce = float( debounce = float(
getattr(self, "_order_debounce_seconds", DISH_LIVE_ORDER_DEBOUNCE_SECONDS) or 0.5 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 (now - last_at) < debounce:
if last_at and (time.time() - last_at) < debounce:
print(f"[live] {debounce:g}s 内菜品未变,跳过存图/订单统计") print(f"[live] {debounce:g}s 内菜品未变,跳过存图/订单统计")
else: else:
print("[live] 菜品未变,跳过存图/订单统计") print("[live] 菜品未变,跳过存图/订单统计")
return False, sig 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 return True, sig
def _update_live_stability(self, dishes): def _update_live_stability(self, dishes):
@ -2360,6 +2535,7 @@ class BaseDishApp(PendingLearningUIMixin):
with self._live_lock: with self._live_lock:
self._latest_frame = frame self._latest_frame = frame
self.show_preview_frame(frame) self.show_preview_frame(frame)
self._update_video_progress_ui()
try: try:
self._stream_recorder.write(frame) self._stream_recorder.write(frame)
except Exception as rec_exc: except Exception as rec_exc:
@ -2746,8 +2922,7 @@ class BaseDishApp(PendingLearningUIMixin):
schedule_check=False, dedupe=True, schedule_check=False, dedupe=True,
) )
if reported: if reported:
self._last_order_dish_sig = order_sig self._remember_order_gate(order_sig, dishes)
self._last_order_at = time.time()
unk_n = sum(1 for d in dishes if d.get("is_unknown")) unk_n = sum(1 for d in dishes if d.get("is_unknown"))
print( print(
f"[order] 本单已上报 known={len(known_dishes)} " f"[order] 本单已上报 known={len(known_dishes)} "
@ -2755,8 +2930,7 @@ class BaseDishApp(PendingLearningUIMixin):
) )
elif order_sig: elif order_sig:
# dedupe 跳过也记为当前单,避免反复落盘 # dedupe 跳过也记为当前单,避免反复落盘
self._last_order_dish_sig = order_sig self._remember_order_gate(order_sig, dishes)
self._last_order_at = time.time()
else: else:
order_unchanged = True order_unchanged = True
print(f"[live] 菜品未变,跳过存图与订单统计 sig={order_sig}") print(f"[live] 菜品未变,跳过存图与订单统计 sig={order_sig}")
@ -3265,14 +3439,22 @@ class BaseDishApp(PendingLearningUIMixin):
self.camera_add_btn.config(state=tk.NORMAL) self.camera_add_btn.config(state=tk.NORMAL)
except Exception: except Exception:
pass pass
self._show_video_progress_bar(True)
self.start_live_scan() self.start_live_scan()
self._refresh_record_btn() self._refresh_record_btn()
def _update_camera_ui_fail(self): def _update_camera_ui_fail(self):
name = self.camera_display_name 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_live_scan()
self.stop_preview_loop() self.stop_preview_loop()
self._show_video_progress_bar(False)
self._set_result_text(tip, "err") self._set_result_text(tip, "err")
if hasattr(self, "camera_badge"): if hasattr(self, "camera_badge"):
self.camera_badge.set(f"{name}未连接", "err") self.camera_badge.set(f"{name}未连接", "err")