主食判断,菜品菜单判断

This commit is contained in:
wanghanbo 2026-10-13 10:21:12 +08:00
parent ff6da8fcfe
commit 3dbfcfd388
6 changed files with 282 additions and 118 deletions

1
.gitignore vendored
View File

@ -19,6 +19,7 @@ docs_locked_backup/
images/
cutting/
detect/
videos/
api_mock_results/
media_store/
central_server/data/

View File

@ -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):

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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()
# ---------------------------------------------------------------
# 业务动作