「手」「空盘」直接丢弃,不裁剪、不识别、不上报,
同一盘在约 1.5 秒内再次识别,即使个别菜名不同,只要检测框布局相近,就不再重复上传
This commit is contained in:
parent
73aeba87a2
commit
678706454a
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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("<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_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")
|
||||
|
|
|
|||
Loading…
Reference in New Issue