From 3dbfcfd3889cb744e7b4f8eaeb869945f3dc7dfe Mon Sep 17 00:00:00 2001
From: wanghanbo <1411232085@qq.com>
Date: Tue, 13 Oct 2026 10:21:12 +0800
Subject: [PATCH] =?UTF-8?q?=E4=B8=BB=E9=A3=9F=E5=88=A4=E6=96=AD=EF=BC=8C?=
=?UTF-8?q?=E8=8F=9C=E5=93=81=E8=8F=9C=E5=8D=95=E5=88=A4=E6=96=AD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.gitignore | 1 +
core/common_classifier.py | 120 ++++++++-------------------
dao/settings.py | 11 ++-
hik_camera_app.py | 13 +--
service/media_server.py | 87 ++++++++++++++++++--
ui/classifier_ui.py | 168 +++++++++++++++++++++++++++++++++++---
6 files changed, 282 insertions(+), 118 deletions(-)
diff --git a/.gitignore b/.gitignore
index cee36ba..f23e728 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,6 +19,7 @@ docs_locked_backup/
images/
cutting/
detect/
+videos/
api_mock_results/
media_store/
central_server/data/
diff --git a/core/common_classifier.py b/core/common_classifier.py
index e00f539..5f071db 100644
--- a/core/common_classifier.py
+++ b/core/common_classifier.py
@@ -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):
diff --git a/dao/settings.py b/dao/settings.py
index b874687..0b1fbcb 100644
--- a/dao/settings.py
+++ b/dao/settings.py
@@ -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
diff --git a/hik_camera_app.py b/hik_camera_app.py
index 2a77adb..63ed211 100644
--- a/hik_camera_app.py
+++ b/hik_camera_app.py
@@ -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)
diff --git a/service/media_server.py b/service/media_server.py
index 9d427cb..d4c7182 100644
--- a/service/media_server.py
+++ b/service/media_server.py
@@ -1,9 +1,11 @@
# coding=utf-8
-"""本机媒体静态服务:把 YOLO 裁剪图等通过 HTTP 暴露,供管理系统访问。
+"""本机媒体 + 客户端 API:静态图 + 管理系统下发的手动学习。
访问形式:
- http://<本机局域网IP>:9100/media///crop_xxx.jpg
+ GET http://<本机局域网IP>:9100/media///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
diff --git a/ui/classifier_ui.py b/ui/classifier_ui.py
index c89933a..9dabe10 100644
--- a/ui/classifier_ui.py
+++ b/ui/classifier_ui.py
@@ -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()
# ---------------------------------------------------------------
# 业务动作