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