主食判断,菜品菜单判断
This commit is contained in:
parent
ff6da8fcfe
commit
3dbfcfd388
|
|
@ -19,6 +19,7 @@ docs_locked_backup/
|
||||||
images/
|
images/
|
||||||
cutting/
|
cutting/
|
||||||
detect/
|
detect/
|
||||||
|
videos/
|
||||||
api_mock_results/
|
api_mock_results/
|
||||||
media_store/
|
media_store/
|
||||||
central_server/data/
|
central_server/data/
|
||||||
|
|
|
||||||
|
|
@ -497,11 +497,9 @@ class PartTemplateManager:
|
||||||
def predict_from_feature(self, feat, image_path=None, auto_pending=True):
|
def predict_from_feature(self, feat, image_path=None, auto_pending=True):
|
||||||
"""已有特征时直接匹配(避免待学习提交时重复提特征)。
|
"""已有特征时直接匹配(避免待学习提交时重复提特征)。
|
||||||
|
|
||||||
有当日排餐且启用时(双路小库):
|
有排餐时:主食小库 ∪ 当前餐段菜品 合并为候选再比对(主食略优先);
|
||||||
A) 主食小库(本地常驻,不在每日排餐中)
|
无排餐 / 无今日菜单:全部未知(不进主食、不进大库)。
|
||||||
B) 今日当前餐段菜品小库
|
均未达标 → 未知,不回退大模板库。
|
||||||
两边分别比对后按规则择优;均未达标 → 未知(不回退大库)
|
|
||||||
早上 cutoff 前且尚无排餐 → 跳过规则,直接走大库。
|
|
||||||
"""
|
"""
|
||||||
if feat is None:
|
if feat is None:
|
||||||
return {
|
return {
|
||||||
|
|
@ -547,41 +545,41 @@ class PartTemplateManager:
|
||||||
period_names = None
|
period_names = None
|
||||||
period_only = False
|
period_only = False
|
||||||
|
|
||||||
# ---------- 1) 有排餐:主食小库 ∥ 当前餐段小库 ----------
|
# ---------- 1) 有排餐:主食 ∪ 当前餐段 合并候选 ----------
|
||||||
if period_only:
|
if period_only:
|
||||||
staple_names = self.staple_candidate_names()
|
staple_names = self.staple_candidate_names()
|
||||||
ranked_st, best_st, max_st = (
|
period_set = set(period_names or [])
|
||||||
self._rank_categories(feat, allowed_names=staple_names)
|
allowed = set(staple_names) | period_set
|
||||||
if staple_names else ([], None, -1.0)
|
ranked, _, _ = (
|
||||||
)
|
self._rank_categories(feat, allowed_names=allowed)
|
||||||
ranked_p, best_p, max_p = self._rank_categories(
|
if allowed else ([], None, -1.0)
|
||||||
feat, allowed_names=period_names or set()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 从合并结果里拆出主食 / 餐段各自最高分
|
||||||
|
best_st, max_st = None, -1.0
|
||||||
|
best_p, max_p = None, -1.0
|
||||||
|
for name, score in ranked:
|
||||||
|
sc = float(score)
|
||||||
|
if name in staple_names and sc > max_st:
|
||||||
|
best_st, max_st = name, sc
|
||||||
|
if name in period_set and sc > max_p:
|
||||||
|
best_p, max_p = name, sc
|
||||||
|
|
||||||
st_hit, st_how = self._score_hit(best_st, max_st, staple_thr, staple_soft)
|
st_hit, st_how = self._score_hit(best_st, max_st, staple_thr, staple_soft)
|
||||||
p_hit, p_how = self._score_hit(best_p, max_p, small_thr, soft_thr)
|
p_hit, p_how = self._score_hit(best_p, max_p, small_thr, soft_thr)
|
||||||
|
|
||||||
chosen = None
|
chosen = None
|
||||||
how = ""
|
how = ""
|
||||||
scope = "period_miss"
|
scope = "period_miss"
|
||||||
# 主食达标,且不低于餐段菜太多 → 优先主食(减轻主食被菜品抢走)
|
# 主食达标,且不低于餐段菜太多 → 优先主食
|
||||||
if st_hit and (not p_hit or float(max_st) >= float(max_p) - float(staple_margin)):
|
if st_hit and (not p_hit or float(max_st) >= float(max_p) - float(staple_margin)):
|
||||||
chosen, how, scope = best_st, f"主食小库{st_how}", "staple"
|
chosen, how, scope = best_st, f"主食+餐段{st_how}", "staple"
|
||||||
elif p_hit:
|
elif p_hit:
|
||||||
chosen, how, scope = best_p, f"当前餐段{p_how}", "small"
|
chosen, how, scope = best_p, f"主食+餐段{p_how}", "small"
|
||||||
elif st_hit:
|
elif st_hit:
|
||||||
chosen, how, scope = best_st, f"主食小库{st_how}", "staple"
|
chosen, how, scope = best_st, f"主食+餐段{st_how}", "staple"
|
||||||
|
|
||||||
# top_k:两路合并取高分
|
top_k = [{"category": n, "score": s} for n, s in (ranked or [])[:3]]
|
||||||
merged = {}
|
|
||||||
for name, score in (ranked_st or [])[:5]:
|
|
||||||
merged[name] = max(merged.get(name, -1.0), float(score))
|
|
||||||
for name, score in (ranked_p or [])[:5]:
|
|
||||||
merged[name] = max(merged.get(name, -1.0), float(score))
|
|
||||||
top_k = [
|
|
||||||
{"category": n, "score": s}
|
|
||||||
for n, s in sorted(merged.items(), key=lambda x: x[1], reverse=True)[:3]
|
|
||||||
]
|
|
||||||
|
|
||||||
if chosen is not None:
|
if chosen is not None:
|
||||||
score = float(max_st) if scope == "staple" else float(max_p)
|
score = float(max_st) if scope == "staple" else float(max_p)
|
||||||
|
|
@ -598,7 +596,6 @@ class PartTemplateManager:
|
||||||
"match_scope": scope,
|
"match_scope": scope,
|
||||||
}
|
}
|
||||||
|
|
||||||
# 未命中:建议取两路最高
|
|
||||||
sug, sug_score = None, -1.0
|
sug, sug_score = None, -1.0
|
||||||
if best_st is not None and float(max_st) > sug_score:
|
if best_st is not None and float(max_st) > sug_score:
|
||||||
sug, sug_score = best_st, float(max_st)
|
sug, sug_score = best_st, float(max_st)
|
||||||
|
|
@ -612,10 +609,10 @@ class PartTemplateManager:
|
||||||
if sug is not None:
|
if sug is not None:
|
||||||
text = (
|
text = (
|
||||||
f"未知菜品 (相似度{sug_score:.3f},最接近:{sug},"
|
f"未知菜品 (相似度{sug_score:.3f},最接近:{sug},"
|
||||||
f"不在主食/当前餐段菜单或未达阈值,已记录待学习)"
|
f"不在主食∪当前餐段或未达阈值,已记录待学习)"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
text = "未知菜品 (主食小库与当前餐段均无匹配模板,已记录待学习)"
|
text = "未知菜品 (主食∪当前餐段均无匹配模板,已记录待学习)"
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"text": text,
|
"text": text,
|
||||||
|
|
@ -629,72 +626,21 @@ class PartTemplateManager:
|
||||||
"match_scope": "period_miss",
|
"match_scope": "period_miss",
|
||||||
}
|
}
|
||||||
|
|
||||||
# ---------- 2) 尚无当日排餐 → 大模版库(仍优先看主食小库) ----------
|
# ---------- 2) 无今日菜单:不进主食、不进大库,全部未知 ----------
|
||||||
staple_names = self.staple_candidate_names()
|
|
||||||
if staple_names:
|
|
||||||
try:
|
|
||||||
from dao.settings import (
|
|
||||||
DISH_STAPLE_LIB_SOFT_THRESHOLD,
|
|
||||||
DISH_STAPLE_LIB_THRESHOLD,
|
|
||||||
)
|
|
||||||
staple_thr = float(DISH_STAPLE_LIB_THRESHOLD)
|
|
||||||
staple_soft = float(DISH_STAPLE_LIB_SOFT_THRESHOLD)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
ranked_st, best_st, max_st = self._rank_categories(
|
|
||||||
feat, allowed_names=staple_names
|
|
||||||
)
|
|
||||||
st_hit, st_how = self._score_hit(best_st, max_st, staple_thr, staple_soft)
|
|
||||||
if st_hit:
|
|
||||||
text = f"{best_st} 相似度:{max_st:.3f}(主食小库{st_how})"
|
|
||||||
return {
|
|
||||||
"text": text,
|
|
||||||
"is_unknown": False,
|
|
||||||
"label": best_st,
|
|
||||||
"score": float(max_st),
|
|
||||||
"suggested_category": best_st,
|
|
||||||
"top_k": [{"category": n, "score": s} for n, s in ranked_st[:3]],
|
|
||||||
"pending_id": None,
|
|
||||||
"feature": feat,
|
|
||||||
"match_scope": "staple",
|
|
||||||
}
|
|
||||||
|
|
||||||
ranked, best_cls, max_sim = self._rank_categories(feat)
|
|
||||||
top_k = [{"category": name, "score": score} for name, score in ranked[:3]]
|
|
||||||
|
|
||||||
if max_sim >= self.threshold:
|
|
||||||
text = f"{best_cls} 相似度:{max_sim:.3f}"
|
|
||||||
return {
|
|
||||||
"text": text,
|
|
||||||
"is_unknown": False,
|
|
||||||
"label": best_cls,
|
|
||||||
"score": max_sim,
|
|
||||||
"suggested_category": best_cls,
|
|
||||||
"top_k": top_k,
|
|
||||||
"pending_id": None,
|
|
||||||
"feature": feat,
|
|
||||||
"match_scope": "big",
|
|
||||||
}
|
|
||||||
|
|
||||||
pending_id = None
|
pending_id = None
|
||||||
if auto_pending and image_path:
|
if auto_pending and image_path:
|
||||||
pending_id = self.pending_store.add(image_path, feat, best_cls, max_sim)
|
pending_id = self.pending_store.add(image_path, feat, None, None)
|
||||||
|
|
||||||
if best_cls:
|
|
||||||
text = f"未知菜品 (相似度{max_sim:.3f},最接近:{best_cls},已记录待学习)"
|
|
||||||
else:
|
|
||||||
text = f"未知菜品 (相似度{max_sim:.3f},模板库为空,已记录待学习)"
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"text": text,
|
"text": "未知菜品 (无今日菜单,不匹配主食/大库,已记录待学习)",
|
||||||
"is_unknown": True,
|
"is_unknown": True,
|
||||||
"label": None,
|
"label": None,
|
||||||
"score": max_sim,
|
"score": None,
|
||||||
"suggested_category": best_cls,
|
"suggested_category": None,
|
||||||
"top_k": top_k,
|
"top_k": [],
|
||||||
"pending_id": pending_id,
|
"pending_id": pending_id,
|
||||||
"feature": feat,
|
"feature": feat,
|
||||||
"match_scope": match_scope,
|
"match_scope": "no_menu",
|
||||||
}
|
}
|
||||||
|
|
||||||
def predict_detail(self, image_path, auto_pending=True):
|
def predict_detail(self, image_path, auto_pending=True):
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,7 @@ DISH_MEAL_CUTOFF_HOUR = int(os.environ.get('DISH_MEAL_CUTOFF_HOUR', '7'))
|
||||||
# 主食小库:不在每日排餐中,常驻候选;与「当前餐段菜品小库」双路匹配
|
# 主食小库:不在每日排餐中,常驻候选;与「当前餐段菜品小库」双路匹配
|
||||||
# 环境变量 DISH_STAPLE_NAMES 逗号分隔覆盖;仅当本地模板库已有同名模板时才参与比对
|
# 环境变量 DISH_STAPLE_NAMES 逗号分隔覆盖;仅当本地模板库已有同名模板时才参与比对
|
||||||
_DISH_STAPLE_NAMES_DEFAULT = (
|
_DISH_STAPLE_NAMES_DEFAULT = (
|
||||||
'米饭,白米饭,杂粮饭,糙米饭,馒头,花卷,包子,面条,汤面,炒饭,盖浇饭,粥,白粥,小米粥,馄饨,饺子'
|
'米饭,白米饭,杂粮饭,黑米饭,馒头,花卷,包子,面条,汤面,炒饭,盖浇饭,粥,白粥,小米粥,馄饨,饺子'
|
||||||
)
|
)
|
||||||
DISH_STAPLE_NAMES = [
|
DISH_STAPLE_NAMES = [
|
||||||
x.strip() for x in os.environ.get('DISH_STAPLE_NAMES', _DISH_STAPLE_NAMES_DEFAULT).split(',')
|
x.strip() for x in os.environ.get('DISH_STAPLE_NAMES', _DISH_STAPLE_NAMES_DEFAULT).split(',')
|
||||||
|
|
@ -114,6 +114,15 @@ DISH_LIVE_ORDER_DEBOUNCE_SECONDS = float(os.environ.get('DISH_LIVE_ORDER_DEBOUNC
|
||||||
# 无餐盘持续该秒数后清场解锁,允许下一盘(含相同菜名)重新存图/上报
|
# 无餐盘持续该秒数后清场解锁,允许下一盘(含相同菜名)重新存图/上报
|
||||||
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'))
|
||||||
|
|
||||||
|
# 相机预览流本地录像:相机连接后自动录,只保留最近 N 分钟(滚动删除旧段)
|
||||||
|
DISH_STREAM_RECORD_ENABLED = os.environ.get('DISH_STREAM_RECORD_ENABLED', '1') == '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'))
|
||||||
|
# 单段时长(秒),默认 5 分钟一切,便于清理
|
||||||
|
DISH_STREAM_SEGMENT_SECONDS = float(os.environ.get('DISH_STREAM_SEGMENT_SECONDS', '300'))
|
||||||
|
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'))
|
||||||
|
|
||||||
|
|
||||||
DEBUG = True
|
DEBUG = True
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@ class ImageClassifierDemo(BaseDishApp):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def take_live_frame(self):
|
def take_live_frame(self):
|
||||||
"""识别用:优先复用预览缓存帧;否则再取一帧并落盘。"""
|
"""识别用:优先复用预览缓存帧;不落盘(有订单时由 _save_order_image 写入)。"""
|
||||||
frame = None
|
frame = None
|
||||||
with getattr(self, "_live_lock", self._frame_lock):
|
with getattr(self, "_live_lock", self._frame_lock):
|
||||||
latest = getattr(self, "_latest_frame", None)
|
latest = getattr(self, "_latest_frame", None)
|
||||||
|
|
@ -90,16 +90,7 @@ class ImageClassifierDemo(BaseDishApp):
|
||||||
frame = self.grab_preview_frame()
|
frame = self.grab_preview_frame()
|
||||||
if frame is None:
|
if frame is None:
|
||||||
return -1, None, None
|
return -1, None, None
|
||||||
os.makedirs("images", exist_ok=True)
|
return 0, None, frame
|
||||||
stamp = int(time.time() * 1000)
|
|
||||||
save_path = os.path.abspath(os.path.join("images", f"_live_frame_{stamp}.jpg"))
|
|
||||||
try:
|
|
||||||
if cv2.imwrite(save_path, frame):
|
|
||||||
self._cleanup_old_live_frames(keep=30)
|
|
||||||
return 0, save_path, frame
|
|
||||||
except Exception as e:
|
|
||||||
print(f"海康实时取帧失败: {e}")
|
|
||||||
return -1, None, None
|
|
||||||
|
|
||||||
def take_photo(self):
|
def take_photo(self):
|
||||||
os.makedirs("images", exist_ok=True)
|
os.makedirs("images", exist_ok=True)
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
# coding=utf-8
|
# coding=utf-8
|
||||||
"""本机媒体静态服务:把 YOLO 裁剪图等通过 HTTP 暴露,供管理系统访问。
|
"""本机媒体 + 客户端 API:静态图 + 管理系统下发的手动学习。
|
||||||
|
|
||||||
访问形式:
|
访问形式:
|
||||||
http://<本机局域网IP>:9100/media/<base>/<request_id>/crop_xxx.jpg
|
GET http://<本机局域网IP>:9100/media/<base>/<request_id>/crop_xxx.jpg
|
||||||
|
POST http://<本机局域网IP>:9100/api/client/manual-learn
|
||||||
"""
|
"""
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import socket
|
import socket
|
||||||
import threading
|
import threading
|
||||||
|
|
@ -12,6 +14,9 @@ from urllib.parse import unquote
|
||||||
|
|
||||||
from dish_server.media_store import MEDIA_ROOT, MediaStore
|
from dish_server.media_store import MEDIA_ROOT, MediaStore
|
||||||
|
|
||||||
|
_MANUAL_LEARN_PATH = "/api/client/manual-learn"
|
||||||
|
_MAX_JSON_BYTES = 2 * 1024 * 1024
|
||||||
|
|
||||||
_DEFAULT_PORT = int(os.environ.get('DISH_MEDIA_SERVER_PORT', '9100'))
|
_DEFAULT_PORT = int(os.environ.get('DISH_MEDIA_SERVER_PORT', '9100'))
|
||||||
_ENABLED = os.environ.get('DISH_MEDIA_SERVER_ENABLED', '1') == '1'
|
_ENABLED = os.environ.get('DISH_MEDIA_SERVER_ENABLED', '1') == '1'
|
||||||
|
|
||||||
|
|
@ -44,24 +49,89 @@ class _MediaHandler(SimpleHTTPRequestHandler):
|
||||||
|
|
||||||
def end_headers(self):
|
def end_headers(self):
|
||||||
self.send_header('Access-Control-Allow-Origin', '*')
|
self.send_header('Access-Control-Allow-Origin', '*')
|
||||||
|
self.send_header('Access-Control-Allow-Headers', 'Content-Type, X-API-Key, X-Base-Code')
|
||||||
|
self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
||||||
self.send_header('Cache-Control', 'public, max-age=60')
|
self.send_header('Cache-Control', 'public, max-age=60')
|
||||||
super().end_headers()
|
super().end_headers()
|
||||||
|
|
||||||
|
def do_OPTIONS(self):
|
||||||
|
self.send_response(204)
|
||||||
|
self.send_header('Content-Length', '0')
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
def do_GET(self):
|
def do_GET(self):
|
||||||
raw = unquote(self.path.split('?', 1)[0])
|
raw = unquote(self.path.split('?', 1)[0])
|
||||||
if raw == '/health':
|
if raw == '/health':
|
||||||
body = b'ok'
|
self._send_plain(b'ok')
|
||||||
self.send_response(200)
|
return
|
||||||
self.send_header('Content-Type', 'text/plain')
|
if raw == _MANUAL_LEARN_PATH:
|
||||||
self.send_header('Content-Length', str(len(body)))
|
self._send_json(405, {
|
||||||
self.end_headers()
|
'code': 40500,
|
||||||
self.wfile.write(body)
|
'message': '请使用 POST /api/client/manual-learn',
|
||||||
|
'data': None,
|
||||||
|
})
|
||||||
return
|
return
|
||||||
if not raw.startswith('/media/') and raw != '/media':
|
if not raw.startswith('/media/') and raw != '/media':
|
||||||
self.send_error(404, 'Not Found')
|
self.send_error(404, 'Not Found')
|
||||||
return
|
return
|
||||||
super().do_GET()
|
super().do_GET()
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
raw = unquote(self.path.split('?', 1)[0])
|
||||||
|
if raw != _MANUAL_LEARN_PATH:
|
||||||
|
self.send_error(404, 'Not Found')
|
||||||
|
return
|
||||||
|
if not self._check_api_key():
|
||||||
|
self._send_json(401, {'code': 40100, 'message': 'X-API-Key 无效', 'data': None})
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
length = int(self.headers.get('Content-Length') or 0)
|
||||||
|
except ValueError:
|
||||||
|
length = 0
|
||||||
|
if length <= 0 or length > _MAX_JSON_BYTES:
|
||||||
|
self._send_json(400, {'code': 40001, 'message': '请求体过大或为空', 'data': None})
|
||||||
|
return
|
||||||
|
raw_body = self.rfile.read(length)
|
||||||
|
try:
|
||||||
|
payload = json.loads(raw_body.decode('utf-8'))
|
||||||
|
except Exception:
|
||||||
|
self._send_json(400, {'code': 40001, 'message': 'JSON 无法解析', 'data': None})
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
from service.manual_learn import handle_manual_learn
|
||||||
|
status, body = handle_manual_learn(payload)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f'[manual-learn] handler failed: {exc}')
|
||||||
|
self._send_json(500, {'code': 50000, 'message': str(exc), 'data': None})
|
||||||
|
return
|
||||||
|
self._send_json(status, body)
|
||||||
|
|
||||||
|
def _check_api_key(self):
|
||||||
|
try:
|
||||||
|
from dao.settings import DISH_MGMT_API_KEY
|
||||||
|
expected = (DISH_MGMT_API_KEY or '').strip()
|
||||||
|
except Exception:
|
||||||
|
expected = ''
|
||||||
|
if not expected:
|
||||||
|
return True
|
||||||
|
got = (self.headers.get('X-API-Key') or '').strip()
|
||||||
|
return got == expected
|
||||||
|
|
||||||
|
def _send_plain(self, body):
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header('Content-Type', 'text/plain')
|
||||||
|
self.send_header('Content-Length', str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def _send_json(self, status, obj):
|
||||||
|
raw = json.dumps(obj, ensure_ascii=False).encode('utf-8')
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header('Content-Type', 'application/json; charset=utf-8')
|
||||||
|
self.send_header('Content-Length', str(len(raw)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(raw)
|
||||||
|
|
||||||
def translate_path(self, path):
|
def translate_path(self, path):
|
||||||
"""把 /media/xxx 映射到 MEDIA_ROOT/xxx。"""
|
"""把 /media/xxx 映射到 MEDIA_ROOT/xxx。"""
|
||||||
path = unquote(path.split('?', 1)[0])
|
path = unquote(path.split('?', 1)[0])
|
||||||
|
|
@ -122,6 +192,7 @@ def ensure_media_server(port=None, enabled=None):
|
||||||
_thread = threading.Thread(target=_httpd.serve_forever, name='MediaHttpServer', daemon=True)
|
_thread = threading.Thread(target=_httpd.serve_forever, name='MediaHttpServer', daemon=True)
|
||||||
_thread.start()
|
_thread.start()
|
||||||
print(f'[OK] local media fallback: {_public_base} (root={_media_root})')
|
print(f'[OK] local media fallback: {_public_base} (root={_media_root})')
|
||||||
|
print(f'[OK] manual-learn POST http://{lan}:{_port}{_MANUAL_LEARN_PATH}')
|
||||||
return _public_base
|
return _public_base
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,14 @@ 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_STREAM_RECORD_ENABLED,
|
||||||
|
DISH_STREAM_RECORD_DIR,
|
||||||
|
DISH_STREAM_RETENTION_MINUTES,
|
||||||
|
DISH_STREAM_SEGMENT_SECONDS,
|
||||||
|
DISH_STREAM_RECORD_FPS,
|
||||||
|
DISH_STREAM_MAX_WIDTH,
|
||||||
)
|
)
|
||||||
|
from service.stream_recorder import StreamRecorder
|
||||||
from dao.base_registry import bootstrap_default_base, get_active_base, set_default_base
|
from dao.base_registry import bootstrap_default_base, get_active_base, set_default_base
|
||||||
from models.App_settings import AppSetting
|
from models.App_settings import AppSetting
|
||||||
from dao.template_sync import SETTING_LAST_SYNC
|
from dao.template_sync import SETTING_LAST_SYNC
|
||||||
|
|
@ -88,6 +95,14 @@ class BaseDishApp(PendingLearningUIMixin):
|
||||||
print(f'[WARN] bootstrap default base failed: {exc}')
|
print(f'[WARN] bootstrap default base failed: {exc}')
|
||||||
|
|
||||||
self.manager = PartTemplateManager(0.7)
|
self.manager = PartTemplateManager(0.7)
|
||||||
|
try:
|
||||||
|
from service.manual_learn import set_manual_learn_manager
|
||||||
|
set_manual_learn_manager(
|
||||||
|
self.manager,
|
||||||
|
on_done=lambda: self.root.after(0, self.refresh_template_list),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f'[WARN] bind manual-learn manager failed: {exc}')
|
||||||
self.current_img = None
|
self.current_img = None
|
||||||
self.detect_img = None
|
self.detect_img = None
|
||||||
self.camera_connected = False
|
self.camera_connected = False
|
||||||
|
|
@ -114,6 +129,7 @@ class BaseDishApp(PendingLearningUIMixin):
|
||||||
self._cart_result_image = None # 左侧已点区展示的识别整图路径
|
self._cart_result_image = None # 左侧已点区展示的识别整图路径
|
||||||
self._cart_result_photo = None
|
self._cart_result_photo = None
|
||||||
self._template_filter = ""
|
self._template_filter = ""
|
||||||
|
self._template_staple_only = False # 模板库「仅看主食」
|
||||||
self._template_page = 0
|
self._template_page = 0
|
||||||
self._template_page_size = 40 # 大库分页,避免一次渲染上千行闪退
|
self._template_page_size = 40 # 大库分页,避免一次渲染上千行闪退
|
||||||
self._template_matched_cache = []
|
self._template_matched_cache = []
|
||||||
|
|
@ -154,6 +170,14 @@ class BaseDishApp(PendingLearningUIMixin):
|
||||||
self._motion_settle_fp = None
|
self._motion_settle_fp = None
|
||||||
self._motion_still_count = 0 # settling 期间已连续相似的帧数
|
self._motion_still_count = 0 # settling 期间已连续相似的帧数
|
||||||
self._yolo_trigger_pending = False
|
self._yolo_trigger_pending = False
|
||||||
|
self._stream_recorder = StreamRecorder(
|
||||||
|
root_dir=DISH_STREAM_RECORD_DIR,
|
||||||
|
retention_minutes=DISH_STREAM_RETENTION_MINUTES,
|
||||||
|
segment_seconds=DISH_STREAM_SEGMENT_SECONDS,
|
||||||
|
fps=DISH_STREAM_RECORD_FPS,
|
||||||
|
max_width=DISH_STREAM_MAX_WIDTH,
|
||||||
|
enabled=DISH_STREAM_RECORD_ENABLED,
|
||||||
|
)
|
||||||
self.cfg = Config()
|
self.cfg = Config()
|
||||||
self.app = YOLOv11PlateExtractor(self.cfg)
|
self.app = YOLOv11PlateExtractor(self.cfg)
|
||||||
self.final_result = ""
|
self.final_result = ""
|
||||||
|
|
@ -1395,6 +1419,12 @@ class BaseDishApp(PendingLearningUIMixin):
|
||||||
command=self.toggle_live_scan,
|
command=self.toggle_live_scan,
|
||||||
)
|
)
|
||||||
self.camera_btn.pack(side=tk.LEFT, padx=(0, 10))
|
self.camera_btn.pack(side=tk.LEFT, padx=(0, 10))
|
||||||
|
self.record_btn = PillButton(
|
||||||
|
inner, text="开始保存", variant="ghost",
|
||||||
|
command=self.toggle_stream_record,
|
||||||
|
tooltip="将相机画面保存到本地 videos/(滚动保留约半小时)",
|
||||||
|
)
|
||||||
|
self.record_btn.pack(side=tk.LEFT, padx=(0, 10))
|
||||||
PillButton(inner, text="选图识别", variant="primary",
|
PillButton(inner, text="选图识别", variant="primary",
|
||||||
command=self.choose_and_predict_img).pack(side=tk.LEFT, padx=(0, 10))
|
command=self.choose_and_predict_img).pack(side=tk.LEFT, padx=(0, 10))
|
||||||
PillButton(inner, text="清空", variant="ghost",
|
PillButton(inner, text="清空", variant="ghost",
|
||||||
|
|
@ -1405,6 +1435,7 @@ class BaseDishApp(PendingLearningUIMixin):
|
||||||
command=self.show_settings_dialog).pack(side=tk.RIGHT)
|
command=self.show_settings_dialog).pack(side=tk.RIGHT)
|
||||||
# 相机状态同步需要该属性;真正学菜按钮在设置页创建
|
# 相机状态同步需要该属性;真正学菜按钮在设置页创建
|
||||||
self.camera_add_btn = self.camera_btn
|
self.camera_add_btn = self.camera_btn
|
||||||
|
self._refresh_record_btn()
|
||||||
return bar
|
return bar
|
||||||
|
|
||||||
def _build_sidebar(self, parent, c):
|
def _build_sidebar(self, parent, c):
|
||||||
|
|
@ -1422,7 +1453,20 @@ class BaseDishApp(PendingLearningUIMixin):
|
||||||
left_card, placeholder="搜索菜名…", command=self._on_search_change,
|
left_card, placeholder="搜索菜名…", command=self._on_search_change,
|
||||||
bg=c["sidebar"], height=32,
|
bg=c["sidebar"], height=32,
|
||||||
)
|
)
|
||||||
self.search_box.pack(fill=tk.X, pady=(0, 8))
|
self.search_box.pack(fill=tk.X, pady=(0, 6))
|
||||||
|
|
||||||
|
filter_row = tk.Frame(left_card, bg=c["sidebar"])
|
||||||
|
filter_row.pack(fill=tk.X, pady=(0, 8))
|
||||||
|
staple_on = bool(getattr(self, "_template_staple_only", False))
|
||||||
|
self._tpl_staple_btn = PillButton(
|
||||||
|
filter_row,
|
||||||
|
text="仅看主食 ✓" if staple_on else "仅看主食",
|
||||||
|
variant="secondary" if staple_on else "ghost",
|
||||||
|
size="sm",
|
||||||
|
command=self._toggle_staple_filter,
|
||||||
|
tooltip="只显示主食小库配置且本地已有模板的菜品",
|
||||||
|
)
|
||||||
|
self._tpl_staple_btn.pack(side=tk.LEFT)
|
||||||
|
|
||||||
list_wrap, self.scrollable_frame, self._template_canvas = self._make_scroll_area(
|
list_wrap, self.scrollable_frame, self._template_canvas = self._make_scroll_area(
|
||||||
left_card, c["sidebar"])
|
left_card, c["sidebar"])
|
||||||
|
|
@ -1536,6 +1580,27 @@ class BaseDishApp(PendingLearningUIMixin):
|
||||||
self._template_page = 0
|
self._template_page = 0
|
||||||
self.refresh_template_list()
|
self.refresh_template_list()
|
||||||
|
|
||||||
|
def _toggle_staple_filter(self):
|
||||||
|
"""模板库切换「仅看主食」。"""
|
||||||
|
self._template_staple_only = not bool(getattr(self, "_template_staple_only", False))
|
||||||
|
btn = getattr(self, "_tpl_staple_btn", None)
|
||||||
|
if btn is not None:
|
||||||
|
try:
|
||||||
|
btn.set_variant("secondary" if self._template_staple_only else "ghost")
|
||||||
|
btn.set_text("仅看主食 ✓" if self._template_staple_only else "仅看主食")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._template_page = 0
|
||||||
|
self.refresh_template_list()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _staple_name_set():
|
||||||
|
try:
|
||||||
|
from dao.settings import DISH_STAPLE_NAMES
|
||||||
|
return {str(n).strip() for n in (DISH_STAPLE_NAMES or []) if str(n).strip()}
|
||||||
|
except Exception:
|
||||||
|
return set()
|
||||||
|
|
||||||
def _template_turn_page(self, delta):
|
def _template_turn_page(self, delta):
|
||||||
page_size = int(getattr(self, "_template_page_size", 40) or 40)
|
page_size = int(getattr(self, "_template_page_size", 40) or 40)
|
||||||
matched = getattr(self, "_template_matched_cache", []) or []
|
matched = getattr(self, "_template_matched_cache", []) or []
|
||||||
|
|
@ -1577,6 +1642,8 @@ class BaseDishApp(PendingLearningUIMixin):
|
||||||
|
|
||||||
page_size = int(getattr(self, "_template_page_size", 40) or 40)
|
page_size = int(getattr(self, "_template_page_size", 40) or 40)
|
||||||
keyword = self._template_filter
|
keyword = self._template_filter
|
||||||
|
staple_only = bool(getattr(self, "_template_staple_only", False))
|
||||||
|
staples = self._staple_name_set() if staple_only else set()
|
||||||
|
|
||||||
if reuse_cache and getattr(self, "_template_matched_cache", None) is not None:
|
if reuse_cache and getattr(self, "_template_matched_cache", None) is not None:
|
||||||
matched = list(self._template_matched_cache)
|
matched = list(self._template_matched_cache)
|
||||||
|
|
@ -1589,10 +1656,13 @@ class BaseDishApp(PendingLearningUIMixin):
|
||||||
except Exception:
|
except Exception:
|
||||||
items = []
|
items = []
|
||||||
visible = [(n, cnt, th) for n, cnt, th in items if cnt > 0]
|
visible = [(n, cnt, th) for n, cnt, th in items if cnt > 0]
|
||||||
matched = [
|
matched = []
|
||||||
(n, cnt, th) for n, cnt, th in visible
|
for n, cnt, th in visible:
|
||||||
if not keyword or keyword in (n or "").lower()
|
if staple_only and (n or "").strip() not in staples:
|
||||||
]
|
continue
|
||||||
|
if keyword and keyword not in (n or "").lower():
|
||||||
|
continue
|
||||||
|
matched.append((n, cnt, th))
|
||||||
# 稳定排序,方便翻页
|
# 稳定排序,方便翻页
|
||||||
matched.sort(key=lambda x: x[0] or "")
|
matched.sort(key=lambda x: x[0] or "")
|
||||||
self._template_matched_cache = matched
|
self._template_matched_cache = matched
|
||||||
|
|
@ -1626,7 +1696,13 @@ class BaseDishApp(PendingLearningUIMixin):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if not matched:
|
if not matched:
|
||||||
if visible_n and keyword:
|
if staple_only and visible_n:
|
||||||
|
EmptyState(
|
||||||
|
self.scrollable_frame, text="暂无主食模板",
|
||||||
|
hint="配置名单内尚无本地模板;先学几张米饭/馒头等同名模板",
|
||||||
|
glyph="◌", bg=c["sidebar"], pady=22,
|
||||||
|
).pack(fill=tk.X)
|
||||||
|
elif visible_n and keyword:
|
||||||
EmptyState(
|
EmptyState(
|
||||||
self.scrollable_frame, text="没有匹配的菜品",
|
self.scrollable_frame, text="没有匹配的菜品",
|
||||||
hint=f"未找到包含「{keyword}」的模板,试试其他关键词",
|
hint=f"未找到包含「{keyword}」的模板,试试其他关键词",
|
||||||
|
|
@ -1640,8 +1716,9 @@ class BaseDishApp(PendingLearningUIMixin):
|
||||||
).pack(fill=tk.X)
|
).pack(fill=tk.X)
|
||||||
stats = getattr(self, "template_stats", None)
|
stats = getattr(self, "template_stats", None)
|
||||||
if self._widget_alive(stats):
|
if self._widget_alive(stats):
|
||||||
if keyword:
|
if staple_only or keyword:
|
||||||
stats.config(text=f"筛选 0 / {visible_n} 个菜品")
|
label = "主食" if staple_only else "筛选"
|
||||||
|
stats.config(text=f"{label} 0 / {visible_n} 个菜品")
|
||||||
else:
|
else:
|
||||||
stats.config(text="共 0 个菜品 · 0 张模板")
|
stats.config(text="共 0 个菜品 · 0 张模板")
|
||||||
return
|
return
|
||||||
|
|
@ -1739,9 +1816,14 @@ class BaseDishApp(PendingLearningUIMixin):
|
||||||
|
|
||||||
stats = getattr(self, "template_stats", None)
|
stats = getattr(self, "template_stats", None)
|
||||||
if self._widget_alive(stats):
|
if self._widget_alive(stats):
|
||||||
if keyword:
|
if staple_only or keyword:
|
||||||
|
label = "主食" if staple_only else "筛选"
|
||||||
|
matched_tpl = sum(cnt for _, cnt, _ in matched)
|
||||||
stats.config(
|
stats.config(
|
||||||
text=f"筛选 {dish_n} / {visible_n} 个菜品 · 本页 {len(page_items)} 条"
|
text=(
|
||||||
|
f"{label} {dish_n} / {visible_n} 个菜品 · "
|
||||||
|
f"{matched_tpl} 张模板 · 本页 {len(page_items)} 条"
|
||||||
|
)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
stats.config(
|
stats.config(
|
||||||
|
|
@ -2075,12 +2157,13 @@ class BaseDishApp(PendingLearningUIMixin):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def start_preview_loop(self):
|
def start_preview_loop(self):
|
||||||
"""相机连接后启动流畅预览(与识别解耦)。"""
|
"""相机连接后启动流畅预览(与识别解耦)。录像由「开始保存」按钮控制。"""
|
||||||
if not self.camera_connected:
|
if not self.camera_connected:
|
||||||
return
|
return
|
||||||
self._preview_enabled = True
|
self._preview_enabled = True
|
||||||
if self._preview_job is None:
|
if self._preview_job is None:
|
||||||
self._schedule_preview_tick(immediate=True)
|
self._schedule_preview_tick(immediate=True)
|
||||||
|
self._refresh_record_btn()
|
||||||
|
|
||||||
def stop_preview_loop(self):
|
def stop_preview_loop(self):
|
||||||
self._preview_enabled = False
|
self._preview_enabled = False
|
||||||
|
|
@ -2094,6 +2177,63 @@ class BaseDishApp(PendingLearningUIMixin):
|
||||||
with self._live_lock:
|
with self._live_lock:
|
||||||
self._latest_frame = None
|
self._latest_frame = None
|
||||||
self._live_overlay_dishes = []
|
self._live_overlay_dishes = []
|
||||||
|
# 预览停了就无法继续写帧,同步停止录像
|
||||||
|
try:
|
||||||
|
self._stream_recorder.stop()
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[record] stop failed: {exc}")
|
||||||
|
self._refresh_record_btn()
|
||||||
|
|
||||||
|
def toggle_stream_record(self):
|
||||||
|
"""底栏:开始保存 / 停止保存 本地录像。"""
|
||||||
|
rec = getattr(self, "_stream_recorder", None)
|
||||||
|
if rec is None or not getattr(rec, "enabled", False):
|
||||||
|
messagebox.showinfo("提示", "本地录像功能未启用(DISH_STREAM_RECORD_ENABLED=0)")
|
||||||
|
return
|
||||||
|
if rec.is_recording:
|
||||||
|
rec.stop()
|
||||||
|
self._toast("已停止保存本地录像", "info")
|
||||||
|
self._set_result_text("本地录像已停止", "ok")
|
||||||
|
else:
|
||||||
|
if not self.camera_connected:
|
||||||
|
messagebox.showwarning("提示", f"{self.camera_display_name}未连接,无法保存视频流")
|
||||||
|
return
|
||||||
|
# 确保预览在跑,否则没有帧可写
|
||||||
|
if not getattr(self, "_preview_enabled", False):
|
||||||
|
self.start_preview_loop()
|
||||||
|
if not rec.start():
|
||||||
|
messagebox.showwarning("提示", "无法开始保存,请检查配置与磁盘目录")
|
||||||
|
return
|
||||||
|
mins = float(getattr(rec, "retention_minutes", 30) or 30)
|
||||||
|
self._toast(f"已开始保存本地录像(滚动保留约 {mins:g} 分钟)", "ok")
|
||||||
|
self._set_result_text(
|
||||||
|
f"本地录像保存中 → {rec.root_dir}(保留约 {mins:g} 分钟)", "ok"
|
||||||
|
)
|
||||||
|
self._refresh_record_btn()
|
||||||
|
|
||||||
|
def _refresh_record_btn(self):
|
||||||
|
btn = getattr(self, "record_btn", None)
|
||||||
|
if btn is None:
|
||||||
|
return
|
||||||
|
rec = getattr(self, "_stream_recorder", None)
|
||||||
|
recording = bool(rec and rec.is_recording)
|
||||||
|
try:
|
||||||
|
if recording:
|
||||||
|
btn.set_text("停止保存")
|
||||||
|
btn.set_variant("danger")
|
||||||
|
else:
|
||||||
|
btn.set_text("开始保存")
|
||||||
|
btn.set_variant("ghost")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# 功能关闭或相机未连时仍可点「开始」给提示;录像中允许随时停
|
||||||
|
try:
|
||||||
|
if rec is None or not getattr(rec, "enabled", False):
|
||||||
|
btn.config(state=tk.DISABLED)
|
||||||
|
else:
|
||||||
|
btn.config(state=tk.NORMAL)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def start_live_scan(self):
|
def start_live_scan(self):
|
||||||
if not self.camera_connected:
|
if not self.camera_connected:
|
||||||
|
|
@ -2150,6 +2290,10 @@ 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)
|
||||||
|
try:
|
||||||
|
self._stream_recorder.write(frame)
|
||||||
|
except Exception as rec_exc:
|
||||||
|
print(f"[record] tick write failed: {rec_exc}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[live] preview tick failed: {e}")
|
print(f"[live] preview tick failed: {e}")
|
||||||
self._schedule_preview_tick()
|
self._schedule_preview_tick()
|
||||||
|
|
@ -2975,6 +3119,7 @@ class BaseDishApp(PendingLearningUIMixin):
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
self.start_live_scan()
|
self.start_live_scan()
|
||||||
|
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
|
||||||
|
|
@ -2991,6 +3136,7 @@ class BaseDishApp(PendingLearningUIMixin):
|
||||||
self.camera_add_btn.config(state=tk.DISABLED)
|
self.camera_add_btn.config(state=tk.DISABLED)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
self._refresh_record_btn()
|
||||||
|
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
# 业务动作
|
# 业务动作
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue