主食判断,菜品菜单判断
This commit is contained in:
parent
ff6da8fcfe
commit
3dbfcfd388
|
|
@ -19,6 +19,7 @@ docs_locked_backup/
|
|||
images/
|
||||
cutting/
|
||||
detect/
|
||||
videos/
|
||||
api_mock_results/
|
||||
media_store/
|
||||
central_server/data/
|
||||
|
|
|
|||
|
|
@ -497,11 +497,9 @@ class PartTemplateManager:
|
|||
def predict_from_feature(self, feat, image_path=None, auto_pending=True):
|
||||
"""已有特征时直接匹配(避免待学习提交时重复提特征)。
|
||||
|
||||
有当日排餐且启用时(双路小库):
|
||||
A) 主食小库(本地常驻,不在每日排餐中)
|
||||
B) 今日当前餐段菜品小库
|
||||
两边分别比对后按规则择优;均未达标 → 未知(不回退大库)
|
||||
早上 cutoff 前且尚无排餐 → 跳过规则,直接走大库。
|
||||
有排餐时:主食小库 ∪ 当前餐段菜品 合并为候选再比对(主食略优先);
|
||||
无排餐 / 无今日菜单:全部未知(不进主食、不进大库)。
|
||||
均未达标 → 未知,不回退大模板库。
|
||||
"""
|
||||
if feat is None:
|
||||
return {
|
||||
|
|
@ -547,41 +545,41 @@ class PartTemplateManager:
|
|||
period_names = None
|
||||
period_only = False
|
||||
|
||||
# ---------- 1) 有排餐:主食小库 ∥ 当前餐段小库 ----------
|
||||
# ---------- 1) 有排餐:主食 ∪ 当前餐段 合并候选 ----------
|
||||
if period_only:
|
||||
staple_names = self.staple_candidate_names()
|
||||
ranked_st, best_st, max_st = (
|
||||
self._rank_categories(feat, allowed_names=staple_names)
|
||||
if staple_names else ([], None, -1.0)
|
||||
)
|
||||
ranked_p, best_p, max_p = self._rank_categories(
|
||||
feat, allowed_names=period_names or set()
|
||||
period_set = set(period_names or [])
|
||||
allowed = set(staple_names) | period_set
|
||||
ranked, _, _ = (
|
||||
self._rank_categories(feat, allowed_names=allowed)
|
||||
if allowed else ([], None, -1.0)
|
||||
)
|
||||
|
||||
# 从合并结果里拆出主食 / 餐段各自最高分
|
||||
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)
|
||||
p_hit, p_how = self._score_hit(best_p, max_p, small_thr, soft_thr)
|
||||
|
||||
chosen = None
|
||||
how = ""
|
||||
scope = "period_miss"
|
||||
# 主食达标,且不低于餐段菜太多 → 优先主食(减轻主食被菜品抢走)
|
||||
# 主食达标,且不低于餐段菜太多 → 优先主食
|
||||
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:
|
||||
chosen, how, scope = best_p, f"当前餐段{p_how}", "small"
|
||||
chosen, how, scope = best_p, f"主食+餐段{p_how}", "small"
|
||||
elif st_hit:
|
||||
chosen, how, scope = best_st, f"主食小库{st_how}", "staple"
|
||||
chosen, how, scope = best_st, f"主食+餐段{st_how}", "staple"
|
||||
|
||||
# top_k:两路合并取高分
|
||||
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]
|
||||
]
|
||||
top_k = [{"category": n, "score": s} for n, s in (ranked or [])[:3]]
|
||||
|
||||
if chosen is not None:
|
||||
score = float(max_st) if scope == "staple" else float(max_p)
|
||||
|
|
@ -598,7 +596,6 @@ class PartTemplateManager:
|
|||
"match_scope": scope,
|
||||
}
|
||||
|
||||
# 未命中:建议取两路最高
|
||||
sug, sug_score = None, -1.0
|
||||
if best_st is not None and float(max_st) > sug_score:
|
||||
sug, sug_score = best_st, float(max_st)
|
||||
|
|
@ -612,10 +609,10 @@ class PartTemplateManager:
|
|||
if sug is not None:
|
||||
text = (
|
||||
f"未知菜品 (相似度{sug_score:.3f},最接近:{sug},"
|
||||
f"不在主食/当前餐段菜单或未达阈值,已记录待学习)"
|
||||
f"不在主食∪当前餐段或未达阈值,已记录待学习)"
|
||||
)
|
||||
else:
|
||||
text = "未知菜品 (主食小库与当前餐段均无匹配模板,已记录待学习)"
|
||||
text = "未知菜品 (主食∪当前餐段均无匹配模板,已记录待学习)"
|
||||
|
||||
return {
|
||||
"text": text,
|
||||
|
|
@ -629,72 +626,21 @@ class PartTemplateManager:
|
|||
"match_scope": "period_miss",
|
||||
}
|
||||
|
||||
# ---------- 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",
|
||||
}
|
||||
|
||||
# ---------- 2) 无今日菜单:不进主食、不进大库,全部未知 ----------
|
||||
pending_id = None
|
||||
if auto_pending and image_path:
|
||||
pending_id = self.pending_store.add(image_path, feat, best_cls, max_sim)
|
||||
|
||||
if best_cls:
|
||||
text = f"未知菜品 (相似度{max_sim:.3f},最接近:{best_cls},已记录待学习)"
|
||||
else:
|
||||
text = f"未知菜品 (相似度{max_sim:.3f},模板库为空,已记录待学习)"
|
||||
pending_id = self.pending_store.add(image_path, feat, None, None)
|
||||
|
||||
return {
|
||||
"text": text,
|
||||
"text": "未知菜品 (无今日菜单,不匹配主食/大库,已记录待学习)",
|
||||
"is_unknown": True,
|
||||
"label": None,
|
||||
"score": max_sim,
|
||||
"suggested_category": best_cls,
|
||||
"top_k": top_k,
|
||||
"score": None,
|
||||
"suggested_category": None,
|
||||
"top_k": [],
|
||||
"pending_id": pending_id,
|
||||
"feature": feat,
|
||||
"match_scope": match_scope,
|
||||
"match_scope": "no_menu",
|
||||
}
|
||||
|
||||
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_DEFAULT = (
|
||||
'米饭,白米饭,杂粮饭,糙米饭,馒头,花卷,包子,面条,汤面,炒饭,盖浇饭,粥,白粥,小米粥,馄饨,饺子'
|
||||
'米饭,白米饭,杂粮饭,黑米饭,馒头,花卷,包子,面条,汤面,炒饭,盖浇饭,粥,白粥,小米粥,馄饨,饺子'
|
||||
)
|
||||
DISH_STAPLE_NAMES = [
|
||||
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'))
|
||||
|
||||
# 相机预览流本地录像:相机连接后自动录,只保留最近 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
|
||||
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ class ImageClassifierDemo(BaseDishApp):
|
|||
return None
|
||||
|
||||
def take_live_frame(self):
|
||||
"""识别用:优先复用预览缓存帧;否则再取一帧并落盘。"""
|
||||
"""识别用:优先复用预览缓存帧;不落盘(有订单时由 _save_order_image 写入)。"""
|
||||
frame = None
|
||||
with getattr(self, "_live_lock", self._frame_lock):
|
||||
latest = getattr(self, "_latest_frame", None)
|
||||
|
|
@ -90,16 +90,7 @@ class ImageClassifierDemo(BaseDishApp):
|
|||
frame = self.grab_preview_frame()
|
||||
if frame is None:
|
||||
return -1, None, None
|
||||
os.makedirs("images", exist_ok=True)
|
||||
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
|
||||
return 0, None, frame
|
||||
|
||||
def take_photo(self):
|
||||
os.makedirs("images", exist_ok=True)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
# 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 socket
|
||||
import threading
|
||||
|
|
@ -12,6 +14,9 @@ from urllib.parse import unquote
|
|||
|
||||
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'))
|
||||
_ENABLED = os.environ.get('DISH_MEDIA_SERVER_ENABLED', '1') == '1'
|
||||
|
||||
|
|
@ -44,24 +49,89 @@ class _MediaHandler(SimpleHTTPRequestHandler):
|
|||
|
||||
def end_headers(self):
|
||||
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')
|
||||
super().end_headers()
|
||||
|
||||
def do_OPTIONS(self):
|
||||
self.send_response(204)
|
||||
self.send_header('Content-Length', '0')
|
||||
self.end_headers()
|
||||
|
||||
def do_GET(self):
|
||||
raw = unquote(self.path.split('?', 1)[0])
|
||||
if raw == '/health':
|
||||
body = b'ok'
|
||||
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)
|
||||
self._send_plain(b'ok')
|
||||
return
|
||||
if raw == _MANUAL_LEARN_PATH:
|
||||
self._send_json(405, {
|
||||
'code': 40500,
|
||||
'message': '请使用 POST /api/client/manual-learn',
|
||||
'data': None,
|
||||
})
|
||||
return
|
||||
if not raw.startswith('/media/') and raw != '/media':
|
||||
self.send_error(404, 'Not Found')
|
||||
return
|
||||
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):
|
||||
"""把 /media/xxx 映射到 MEDIA_ROOT/xxx。"""
|
||||
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.start()
|
||||
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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -51,7 +51,14 @@ from dao.settings import (
|
|||
DISH_LIVE_MOTION_SIM_WITH_ORDER,
|
||||
DISH_LIVE_ORDER_DEBOUNCE_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 models.App_settings import AppSetting
|
||||
from dao.template_sync import SETTING_LAST_SYNC
|
||||
|
|
@ -88,6 +95,14 @@ class BaseDishApp(PendingLearningUIMixin):
|
|||
print(f'[WARN] bootstrap default base failed: {exc}')
|
||||
|
||||
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.detect_img = None
|
||||
self.camera_connected = False
|
||||
|
|
@ -114,6 +129,7 @@ class BaseDishApp(PendingLearningUIMixin):
|
|||
self._cart_result_image = None # 左侧已点区展示的识别整图路径
|
||||
self._cart_result_photo = None
|
||||
self._template_filter = ""
|
||||
self._template_staple_only = False # 模板库「仅看主食」
|
||||
self._template_page = 0
|
||||
self._template_page_size = 40 # 大库分页,避免一次渲染上千行闪退
|
||||
self._template_matched_cache = []
|
||||
|
|
@ -154,6 +170,14 @@ class BaseDishApp(PendingLearningUIMixin):
|
|||
self._motion_settle_fp = None
|
||||
self._motion_still_count = 0 # settling 期间已连续相似的帧数
|
||||
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.app = YOLOv11PlateExtractor(self.cfg)
|
||||
self.final_result = ""
|
||||
|
|
@ -1395,6 +1419,12 @@ class BaseDishApp(PendingLearningUIMixin):
|
|||
command=self.toggle_live_scan,
|
||||
)
|
||||
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",
|
||||
command=self.choose_and_predict_img).pack(side=tk.LEFT, padx=(0, 10))
|
||||
PillButton(inner, text="清空", variant="ghost",
|
||||
|
|
@ -1405,6 +1435,7 @@ class BaseDishApp(PendingLearningUIMixin):
|
|||
command=self.show_settings_dialog).pack(side=tk.RIGHT)
|
||||
# 相机状态同步需要该属性;真正学菜按钮在设置页创建
|
||||
self.camera_add_btn = self.camera_btn
|
||||
self._refresh_record_btn()
|
||||
return bar
|
||||
|
||||
def _build_sidebar(self, parent, c):
|
||||
|
|
@ -1422,7 +1453,20 @@ class BaseDishApp(PendingLearningUIMixin):
|
|||
left_card, placeholder="搜索菜名…", command=self._on_search_change,
|
||||
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(
|
||||
left_card, c["sidebar"])
|
||||
|
|
@ -1536,6 +1580,27 @@ class BaseDishApp(PendingLearningUIMixin):
|
|||
self._template_page = 0
|
||||
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):
|
||||
page_size = int(getattr(self, "_template_page_size", 40) or 40)
|
||||
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)
|
||||
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:
|
||||
matched = list(self._template_matched_cache)
|
||||
|
|
@ -1589,10 +1656,13 @@ class BaseDishApp(PendingLearningUIMixin):
|
|||
except Exception:
|
||||
items = []
|
||||
visible = [(n, cnt, th) for n, cnt, th in items if cnt > 0]
|
||||
matched = [
|
||||
(n, cnt, th) for n, cnt, th in visible
|
||||
if not keyword or keyword in (n or "").lower()
|
||||
]
|
||||
matched = []
|
||||
for n, cnt, th in visible:
|
||||
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 "")
|
||||
self._template_matched_cache = matched
|
||||
|
|
@ -1626,7 +1696,13 @@ class BaseDishApp(PendingLearningUIMixin):
|
|||
pass
|
||||
|
||||
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(
|
||||
self.scrollable_frame, text="没有匹配的菜品",
|
||||
hint=f"未找到包含「{keyword}」的模板,试试其他关键词",
|
||||
|
|
@ -1640,8 +1716,9 @@ class BaseDishApp(PendingLearningUIMixin):
|
|||
).pack(fill=tk.X)
|
||||
stats = getattr(self, "template_stats", None)
|
||||
if self._widget_alive(stats):
|
||||
if keyword:
|
||||
stats.config(text=f"筛选 0 / {visible_n} 个菜品")
|
||||
if staple_only or keyword:
|
||||
label = "主食" if staple_only else "筛选"
|
||||
stats.config(text=f"{label} 0 / {visible_n} 个菜品")
|
||||
else:
|
||||
stats.config(text="共 0 个菜品 · 0 张模板")
|
||||
return
|
||||
|
|
@ -1739,9 +1816,14 @@ class BaseDishApp(PendingLearningUIMixin):
|
|||
|
||||
stats = getattr(self, "template_stats", None)
|
||||
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(
|
||||
text=f"筛选 {dish_n} / {visible_n} 个菜品 · 本页 {len(page_items)} 条"
|
||||
text=(
|
||||
f"{label} {dish_n} / {visible_n} 个菜品 · "
|
||||
f"{matched_tpl} 张模板 · 本页 {len(page_items)} 条"
|
||||
)
|
||||
)
|
||||
else:
|
||||
stats.config(
|
||||
|
|
@ -2075,12 +2157,13 @@ class BaseDishApp(PendingLearningUIMixin):
|
|||
pass
|
||||
|
||||
def start_preview_loop(self):
|
||||
"""相机连接后启动流畅预览(与识别解耦)。"""
|
||||
"""相机连接后启动流畅预览(与识别解耦)。录像由「开始保存」按钮控制。"""
|
||||
if not self.camera_connected:
|
||||
return
|
||||
self._preview_enabled = True
|
||||
if self._preview_job is None:
|
||||
self._schedule_preview_tick(immediate=True)
|
||||
self._refresh_record_btn()
|
||||
|
||||
def stop_preview_loop(self):
|
||||
self._preview_enabled = False
|
||||
|
|
@ -2094,6 +2177,63 @@ class BaseDishApp(PendingLearningUIMixin):
|
|||
with self._live_lock:
|
||||
self._latest_frame = None
|
||||
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):
|
||||
if not self.camera_connected:
|
||||
|
|
@ -2150,6 +2290,10 @@ class BaseDishApp(PendingLearningUIMixin):
|
|||
with self._live_lock:
|
||||
self._latest_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:
|
||||
print(f"[live] preview tick failed: {e}")
|
||||
self._schedule_preview_tick()
|
||||
|
|
@ -2975,6 +3119,7 @@ class BaseDishApp(PendingLearningUIMixin):
|
|||
except Exception:
|
||||
pass
|
||||
self.start_live_scan()
|
||||
self._refresh_record_btn()
|
||||
|
||||
def _update_camera_ui_fail(self):
|
||||
name = self.camera_display_name
|
||||
|
|
@ -2991,6 +3136,7 @@ class BaseDishApp(PendingLearningUIMixin):
|
|||
self.camera_add_btn.config(state=tk.DISABLED)
|
||||
except Exception:
|
||||
pass
|
||||
self._refresh_record_btn()
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 业务动作
|
||||
|
|
|
|||
Loading…
Reference in New Issue