import os import json import shutil import datetime from collections import defaultdict from pathlib import Path import threading import cv2 import torch import torch.nn as nn import torchvision.models as models from torchvision import transforms from torchvision.models.convnext import ConvNeXt_Tiny_Weights from PIL import Image, ImageTk import numpy as np import tkinter as tk from tkinter import ttk, filedialog, messagebox from core.accel import resolve_feature_openvino, torch_device from core.pending_learning import PendingLearningStore from ui.pending_learning_ui import PendingLearningUIMixin, format_dish_box_label, format_predict_detail # -------------------------- # 设备配置 # -------------------------- device = torch_device() # 模板保存路径配置 TEMPLATE_SAVE_DIR = os.path.join(os.path.expanduser("~"), "ImageClassifierTemplates") TEMPLATE_DATA_FILE = os.path.join(TEMPLATE_SAVE_DIR, "templates_data.json") THUMBNAIL_DIR = os.path.join(TEMPLATE_SAVE_DIR, "thumbnails") # 创建保存目录 os.makedirs(TEMPLATE_SAVE_DIR, exist_ok=True) os.makedirs(THUMBNAIL_DIR, exist_ok=True) _IMAGENET_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32) _IMAGENET_STD = np.array([0.229, 0.224, 0.225], dtype=np.float32) # -------------------------- # 全局字体配置 # -------------------------- def setup_ttk_styles(root=None): """配置 ttk 主题(委托 ui.theme)。""" from ui.theme import apply_theme return apply_theme(root) # -------------------------- # 特征提取模型 # -------------------------- class FeatureExtractor(nn.Module): def __init__(self): super().__init__() self.convnext = models.convnext_tiny(weights=ConvNeXt_Tiny_Weights.DEFAULT) self.features = nn.Sequential(*list(self.convnext.children())[:-1]) for param in self.parameters(): param.requires_grad = False self.feature_dim = 768 def forward(self, x): features = self.features(x) features = features.view(features.size(0), -1) features = nn.functional.normalize(features, p=2, dim=1) return features class OpenVINOFeatureBackend: """可选:用 OpenVINO 跑 ConvNeXt 特征(Intel CPU/iGPU)。""" def __init__(self, model_dir): from pathlib import Path from openvino import Core xmls = sorted(Path(model_dir).glob("*.xml")) if not xmls: raise FileNotFoundError(f"OpenVINO 模型目录无 xml: {model_dir}") core = Core() model = core.read_model(str(xmls[0])) # AUTO 会优先用 Intel GPU(若可用),否则 CPU self.compiled = core.compile_model(model, os.environ.get("DISH_OPENVINO_DEVICE", "AUTO")) self.output_key = self.compiled.outputs[0] self.feature_dim = 768 print(f"[OK] feature OpenVINO loaded: {xmls[0]} device={os.environ.get('DISH_OPENVINO_DEVICE', 'AUTO')}") def __call__(self, batch_nchw: np.ndarray) -> np.ndarray: out = self.compiled([batch_nchw])[self.output_key] feats = np.asarray(out, dtype=np.float32) if feats.ndim > 2: feats = feats.reshape(feats.shape[0], -1) norms = np.linalg.norm(feats, axis=1, keepdims=True) norms = np.maximum(norms, 1e-12) return feats / norms # -------------------------- # 模板管理器 # -------------------------- class PartTemplateManager: def __init__(self, similarity_threshold=0.7): self.transform = transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) self._ov_backend = None ov_dir = resolve_feature_openvino() if ov_dir is not None: try: self._ov_backend = OpenVINOFeatureBackend(ov_dir) self.extractor = None self.feature_dim = 768 except Exception as exc: print(f"[WARN] OpenVINO feature backend failed, fallback torch: {exc}") self._ov_backend = None if self._ov_backend is None: self.extractor = FeatureExtractor().to(device).eval() self.feature_dim = self.extractor.feature_dim print(f"[OK] feature extractor on {device}") self.template_library = defaultdict(list) self.threshold = similarity_threshold self.lock = threading.Lock() # 矩阵相似度缓存:categories[i] 对应 matrix[i] self._sim_matrix = np.zeros((0, 768), dtype=np.float32) self._sim_categories = [] self.pending_store = PendingLearningStore(TEMPLATE_SAVE_DIR) from dao.template_repository import TemplateRepository self.repo = TemplateRepository(json_path=TEMPLATE_DATA_FILE) self.repo.set_reload_callback(self.load_templates) self.load_templates() def get_base_info(self): return self.repo.get_base_info() def sync_now(self, full=True): return self.repo.sync_now(full=full) def get_sync_status(self): return self.repo.get_sync_status() def list_upload_queue(self, statuses=('pending', 'failed'), limit=100): return self.repo.list_upload_queue(statuses=statuses, limit=limit) def retry_failed_uploads(self): return self.repo.retry_failed_uploads() @property def source_base_id(self): return self.repo.source_base_id @property def base_code(self): return getattr(self.repo, 'base_code', None) def _rebuild_sim_index(self): """将模板特征压成 float32 矩阵,供一次 matmul 完成相似度。""" cats = [] rows = [] for cls_name, templates in self.template_library.items(): for temp in templates: feat = temp.get("feature") if feat is None: continue vec = np.asarray(feat, dtype=np.float32).reshape(-1) if vec.size == 0: continue rows.append(vec) cats.append(cls_name) if rows: mat = np.stack(rows, axis=0) # 兜底 L2,避免旧数据未归一化 norms = np.linalg.norm(mat, axis=1, keepdims=True) norms = np.maximum(norms, 1e-12) mat = mat / norms self._sim_matrix = mat self._sim_categories = cats else: self._sim_matrix = np.zeros((0, self.feature_dim), dtype=np.float32) self._sim_categories = [] @staticmethod def _to_pil_rgb(image_like): """路径 / BGR ndarray / RGB ndarray / PIL → RGB PIL。""" if image_like is None: return None if isinstance(image_like, Image.Image): return image_like.convert("RGB") if isinstance(image_like, np.ndarray): arr = image_like if arr.ndim == 2: arr = cv2.cvtColor(arr, cv2.COLOR_GRAY2RGB) elif arr.ndim == 3 and arr.shape[2] == 4: arr = cv2.cvtColor(arr, cv2.COLOR_BGRA2RGB) elif arr.ndim == 3 and arr.shape[2] == 3: # 热路径裁剪来自 OpenCV,默认按 BGR 处理 arr = cv2.cvtColor(arr, cv2.COLOR_BGR2RGB) else: return None return Image.fromarray(arr) if isinstance(image_like, (tuple, list)): image_like = image_like[0] if isinstance(image_like, str): try: image_like = image_like.encode("utf-8").decode("utf-8") except Exception: return None if not os.path.exists(image_like): return None try: with open(image_like, "rb") as f: return Image.open(f).convert("RGB") except Exception: return None return None def _preprocess_pil(self, img: Image.Image) -> np.ndarray: """返回 NCHW float32(单张),与 torchvision 变换一致。""" tensor = self.transform(img) # CHW return tensor.numpy().astype(np.float32) def _infer_features(self, batch_nchw: np.ndarray) -> np.ndarray: """batch: (N,3,224,224) float32 → (N,768) L2-normalized。""" if batch_nchw is None or len(batch_nchw) == 0: return np.zeros((0, self.feature_dim), dtype=np.float32) if self._ov_backend is not None: return self._ov_backend(batch_nchw) with torch.no_grad(): t = torch.from_numpy(batch_nchw).to(device) feats = self.extractor(t).cpu().numpy() return feats.astype(np.float32) def _extract_image_feature(self, image_like): img = self._to_pil_rgb(image_like) if img is None: return None try: nchw = self._preprocess_pil(img)[None, ...] feature = self._infer_features(nchw)[0] if np.isnan(feature).any() or np.isinf(feature).any(): return None return feature except Exception as e: print(f"特征提取失败: {e}") return None def extract_features_batch(self, image_likes): """批量提特征;失败位置为 None。""" results = [None] * len(image_likes) tensors = [] indices = [] for i, src in enumerate(image_likes): img = self._to_pil_rgb(src) if img is None: continue try: tensors.append(self._preprocess_pil(img)) indices.append(i) except Exception as e: print(f"特征预处理失败[{i}]: {e}") if not tensors: return results batch = np.stack(tensors, axis=0) try: feats = self._infer_features(batch) for j, idx in enumerate(indices): feat = feats[j] if np.isnan(feat).any() or np.isinf(feat).any(): results[idx] = None else: results[idx] = feat except Exception as e: print(f"批量特征提取失败: {e}") return results def _create_thumbnail(self, image_path, size=(60, 60)): try: thumbnail_name = f"{hash(image_path)}_{os.path.basename(image_path)}" thumbnail_path = os.path.join(THUMBNAIL_DIR, thumbnail_name) with open(image_path, 'rb') as f: img = Image.open(f).convert("RGB") img.thumbnail(size, Image.Resampling.LANCZOS) img.save(thumbnail_path, "PNG") return thumbnail_path except Exception as e: print(f"生成缩略图失败: {e}") return None def _load_thumbnail(self, thumbnail_path, size=(60, 60)): if not thumbnail_path or not os.path.exists(thumbnail_path): return None try: with open(thumbnail_path, 'rb') as f: img = Image.open(f).convert("RGB") img.thumbnail(size, Image.Resampling.LANCZOS) return ImageTk.PhotoImage(img) except Exception as e: print(f"加载缩略图失败: {e}") return None def add_template(self, part_type, image_paths): if not isinstance(image_paths, list): if isinstance(image_paths, (str, tuple)): image_paths = list(image_paths) else: image_paths = [image_paths] added = 0 add_time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") with self.lock: for img_path in image_paths: feat = self._extract_image_feature(img_path) if feat is not None: thumbnail_path = self._create_thumbnail(img_path) # 避免同名文件互相覆盖:菜名 + 短哈希 stem = Path(img_path).stem suffix = Path(img_path).suffix or ".jpg" digest = abs(hash(os.path.abspath(img_path))) % 10_000_000 safe_name = f"{stem}_{digest}{suffix}" template_img_path = os.path.join(TEMPLATE_SAVE_DIR, safe_name) shutil.copy2(img_path, template_img_path) record = self.repo.insert_template( part_type, template_img_path, feat, thumbnail_path, add_time=add_time, ) self.template_library[part_type].append(record) added += 1 if added: self._rebuild_sim_index() if added > 0: self._notify_learned_async(part_type) return added, len(image_paths) @staticmethod def _notify_learned_async(dish_name): name = (dish_name or '').strip() if not name: return def _work(): try: from service.mgmt_client import notify_dish_learned notify_dish_learned(name) except Exception as exc: print(f'[WARN] notify learned failed: {exc}') threading.Thread(target=_work, daemon=True).start() def add_templates_batch(self, pairs, progress_callback=None): """批量导入 [(菜名, 图片路径), ...],只在最后同步一次,避免每张都 pull 冲掉本地库。 :return: (成功数, 失败列表[(path, reason), ...]) """ prepared = [] failures = [] total = len(pairs) for i, (part_type, img_path) in enumerate(pairs, 1): if progress_callback: progress_callback(i, total, part_type, img_path) name = (part_type or "").strip() if not name: failures.append((img_path, "空菜名")) continue if len(name) > 100: name = name[:100] if not os.path.isfile(img_path): failures.append((img_path, "文件不存在")) continue try: feat = self._extract_image_feature(img_path) except Exception as e: failures.append((img_path, f"特征异常: {e}")) continue if feat is None: failures.append((img_path, "特征提取失败")) continue try: thumbnail_path = self._create_thumbnail(img_path) stem = Path(img_path).stem suffix = Path(img_path).suffix or ".jpg" digest = abs(hash(os.path.abspath(img_path))) % 10_000_000 safe_name = f"{stem}_{digest}{suffix}" template_img_path = os.path.join(TEMPLATE_SAVE_DIR, safe_name) shutil.copy2(img_path, template_img_path) prepared.append({ "category_name": name, "image_url": template_img_path, "feature": np.asarray(feat, dtype=np.float32).tolist(), "thumbnail_url": thumbnail_path or "", }) except Exception as e: failures.append((img_path, f"保存失败: {e}")) if prepared: self.repo.insert_templates_batch(prepared) self.load_templates() # 批量导入:对成功写入的菜名去重后上报「已学习」 learned_names = sorted({item['category_name'] for item in prepared if item.get('category_name')}) for name in learned_names: self._notify_learned_async(name) return len(prepared), failures def delete_template(self, part_type, template_index): if part_type in self.template_library and 0 <= template_index < len(self.template_library[part_type]): template_to_delete = self.template_library[part_type][template_index] deleted = self.repo.delete_template_by_id(template_to_delete.get('id')) if deleted: try: if deleted.get('path') and os.path.exists(deleted['path']): os.remove(deleted['path']) except Exception as e: print(f"删除模板图片失败: {e}") try: if deleted.get('thumbnail') and os.path.exists(deleted['thumbnail']): os.remove(deleted['thumbnail']) except Exception as e: print(f"删除缩略图失败: {e}") with self.lock: del self.template_library[part_type][template_index] if not self.template_library[part_type]: del self.template_library[part_type] self._rebuild_sim_index() return True return False def delete_template_category(self, part_type): if part_type in self.template_library: removed = self.repo.delete_category(part_type) for template in removed: try: if template.get('thumbnail') and os.path.exists(template['thumbnail']): os.remove(template['thumbnail']) except Exception as e: print(f"删除缩略图失败: {e}") with self.lock: if part_type in self.template_library: del self.template_library[part_type] self._rebuild_sim_index() return True return False def _rank_categories(self, feature, allowed_names=None): """按类别聚合最高相似度,返回排序后的候选列表。 allowed_names: 若给定,只在该菜名集合内匹配(小模版库)。 """ feature = np.asarray(feature, dtype=np.float32).reshape(-1) allow = None if allowed_names is not None: allow = {str(n).strip() for n in allowed_names if str(n).strip()} if not allow: return [], None, -1.0 with self.lock: matrix = self._sim_matrix categories = self._sim_categories if matrix.shape[0] == 0: return [], None, -1.0 scores = matrix @ feature # (T,) category_best = {} for cat, sim in zip(categories, scores): if allow is not None and cat not in allow: continue sim_f = float(sim) if sim_f > category_best.get(cat, -1.0): category_best[cat] = sim_f ranked = sorted(category_best.items(), key=lambda x: x[1], reverse=True) return ranked, (ranked[0][0] if ranked else None), (ranked[0][1] if ranked else -1.0) def staple_candidate_names(self): """主食小库候选:配置名单 ∩ 本地已有模板的菜名。""" try: from dao.settings import DISH_STAPLE_NAMES configured = {str(n).strip() for n in (DISH_STAPLE_NAMES or []) if str(n).strip()} except Exception: configured = set() if not configured: return set() with self.lock: available = {str(k).strip() for k in (self.template_library or {}).keys() if str(k).strip()} return configured & available @staticmethod def _score_hit(best, score, hard_thr, soft_thr): """是否达到认定阈值。返回 (hit, how_suffix)。""" if best is None: return False, "" if score >= float(hard_thr): return True, "" if score > float(soft_thr): return True, "·软阈值" return False, "" def predict_from_feature(self, feat, image_path=None, auto_pending=True): """已有特征时直接匹配(避免待学习提交时重复提特征)。 有当日排餐且启用时(双路小库): A) 主食小库(本地常驻,不在每日排餐中) B) 今日当前餐段菜品小库 两边分别比对后按规则择优;均未达标 → 未知(不回退大库) 早上 cutoff 前且尚无排餐 → 跳过规则,直接走大库。 """ if feat is None: return { "text": "特征提取失败", "error": "特征提取失败", "is_unknown": True, "label": None, "score": None, "suggested_category": None, "top_k": [], "pending_id": None, "feature": None, } period_names = None period_only = False small_thr = 0.80 soft_thr = 0.75 staple_thr = 0.80 staple_soft = 0.75 staple_margin = 0.03 match_scope = "big" try: from dao.meal_schedule import get_meal_schedule_store from dao.settings import ( DISH_MEAL_SMALL_LIB_SOFT_THRESHOLD, DISH_MEAL_SMALL_LIB_THRESHOLD, DISH_STAPLE_LIB_SOFT_THRESHOLD, DISH_STAPLE_LIB_THRESHOLD, DISH_STAPLE_PRIORITY_MARGIN, ) store = get_meal_schedule_store() if store.should_use_small_library(): period_only = True period_names = store.current_period_dish_names() small_thr = float(DISH_MEAL_SMALL_LIB_THRESHOLD) soft_thr = float(DISH_MEAL_SMALL_LIB_SOFT_THRESHOLD) staple_thr = float(DISH_STAPLE_LIB_THRESHOLD) staple_soft = float(DISH_STAPLE_LIB_SOFT_THRESHOLD) staple_margin = float(DISH_STAPLE_PRIORITY_MARGIN) except Exception as exc: print(f'[WARN] meal period-menu lookup failed: {exc}') period_names = None period_only = False # ---------- 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() ) 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" elif p_hit: chosen, how, scope = best_p, f"当前餐段{p_how}", "small" elif st_hit: 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] ] if chosen is not None: score = float(max_st) if scope == "staple" else float(max_p) text = f"{chosen} 相似度:{score:.3f}({how})" return { "text": text, "is_unknown": False, "label": chosen, "score": score, "suggested_category": chosen, "top_k": top_k, "pending_id": None, "feature": feat, "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) if best_p is not None and float(max_p) > sug_score: sug, sug_score = best_p, float(max_p) pending_id = None if auto_pending and image_path: pending_id = self.pending_store.add(image_path, feat, sug, sug_score) if sug is not None: text = ( f"未知菜品 (相似度{sug_score:.3f},最接近:{sug}," f"不在主食/当前餐段菜单或未达阈值,已记录待学习)" ) else: text = "未知菜品 (主食小库与当前餐段均无匹配模板,已记录待学习)" return { "text": text, "is_unknown": True, "label": None, "score": sug_score if sug is not None else None, "suggested_category": sug, "top_k": top_k, "pending_id": pending_id, "feature": feat, "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", } 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},模板库为空,已记录待学习)" return { "text": text, "is_unknown": True, "label": None, "score": max_sim, "suggested_category": best_cls, "top_k": top_k, "pending_id": pending_id, "feature": feat, "match_scope": match_scope, } def predict_detail(self, image_path, auto_pending=True): feat = self._extract_image_feature(image_path) path_for_pending = image_path if isinstance(image_path, str) else None return self.predict_from_feature(feat, image_path=path_for_pending, auto_pending=auto_pending) def predict(self, image_path): return self.predict_detail(image_path)['text'] def get_pending_list(self): return self.pending_store.get_list(PendingLearningStore.STATUS_PENDING) def get_pending_count(self): return self.pending_store.count(PendingLearningStore.STATUS_PENDING) def learn_from_pending(self, pending_id, category): """确认学习一条待学习记录(只处理这一条,不轮询其余待学习项)。""" item = self.pending_store.get(pending_id) if not item: return False img_path = item.get("image_path") if not img_path or not os.path.exists(img_path): return False added, _ = self.add_template(category, [img_path]) if added <= 0: return False self.pending_store.mark_learned(pending_id, category) return True def ignore_pending(self, pending_id): return self.pending_store.mark_ignored(pending_id) def delete_pending(self, pending_id): return self.pending_store.delete(pending_id) def get_template_list(self, load_thumbs=False): """返回 [(菜名, 数量, 缩略图路径或PhotoImage|None), ...]。 load_thumbs=False(默认)只返回缩略图路径,避免一次创建上千个 PhotoImage 导致卡死/闪退。 """ template_list = [] with self.lock: for k, v in self.template_library.items(): if v: thumb = v[0].get('thumbnail') or '' if load_thumbs: thumb = self._load_thumbnail(thumb, size=(42, 42)) template_list.append((k, len(v), thumb)) else: template_list.append((k, 0, None)) return template_list def get_template_details(self, part_type, load_thumbs=False): details = [] with self.lock: raw_details = self.template_library.get(part_type, []) for item in raw_details: item_copy = item.copy() if load_thumbs: item_copy['thumbnail_obj'] = self._load_thumbnail(item.get('thumbnail')) else: item_copy['thumbnail_obj'] = None details.append(item_copy) return details def clear_all(self): self.repo.clear_all() with self.lock: self.template_library.clear() self._rebuild_sim_index() self.pending_store.clear_all() for file in os.listdir(THUMBNAIL_DIR): file_path = os.path.join(THUMBNAIL_DIR, file) try: if os.path.isfile(file_path): os.unlink(file_path) except Exception as e: print(f"删除缩略图失败: {e}") def set_threshold(self, threshold): with self.lock: self.threshold = threshold self.repo.set_threshold(threshold) def save_templates(self): """持久化阈值等配置(模板已实时写入数据库)。""" try: self.repo.set_threshold(self.threshold) print(f"[OK] config saved to database ({self.repo.backend_name()})") except Exception as e: print(f"[ERR] save config failed: {e}") def load_templates(self): try: threshold, library = self.repo.load_library() with self.lock: self.threshold = threshold self.template_library = defaultdict(list, library) self._rebuild_sim_index() print(f"[OK] loaded {len(self.template_library)} categories from database ({self.repo.backend_name()})") except Exception as e: print(f"[ERR] load templates failed: {e}") # -------------------------- # UI基础父类(公共UI逻辑) # -------------------------- class BaseClassifierUI(PendingLearningUIMixin): def __init__(self, root): self.root = root self.root.title("图像自动分类工具") self.root.state('zoomed') self.root.encoding = 'utf-8' setup_ttk_styles() self.manager = PartTemplateManager(0.7) self.current_img = None self.detect_img = None self.class_name = tk.StringVar(value="产品A") self.thresh_val = tk.DoubleVar(value=self.manager.threshold) # 车牌检测导入 from core.detect_plate_process import Config, YOLOv11PlateExtractor self.cfg = Config() self.app = YOLOv11PlateExtractor(self.cfg) self.create_ui() def create_ui(self): main_paned = ttk.PanedWindow(self.root, orient=tk.HORIZONTAL) main_paned.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) left_frame = ttk.Frame(main_paned, width=300) main_paned.add(left_frame) ttk.Label(left_frame, text="📋 模板库(缩略图预览)", style='Title.TLabel').pack(pady=5, anchor=tk.W) self.template_frame = ttk.Frame(left_frame) self.template_frame.pack(fill=tk.BOTH, expand=True, pady=5) canvas = tk.Canvas(self.template_frame) scrollbar = ttk.Scrollbar(self.template_frame, orient="vertical", command=canvas.yview) self.scrollable_frame = ttk.Frame(canvas) self.scrollable_frame.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) canvas.create_window((0, 0), window=self.scrollable_frame, anchor="nw") canvas.configure(yscrollcommand=scrollbar.set) canvas.pack(side="left", fill=tk.BOTH, expand=True) scrollbar.pack(side="right", fill=tk.Y) ttk.Button(left_frame, text="🗑️ 清空模板库", command=self.clear_lib, style='Large.TButton').pack(fill=tk.X, pady=3) self.setup_pending_learning_ui(left_frame) right_frame = ttk.Frame(main_paned) main_paned.add(right_frame) top_frame = ttk.Frame(right_frame) top_frame.pack(fill=tk.X, pady=5) ttk.Label(top_frame, text="相似度阈值:", style='Medium.TLabel').pack(side=tk.LEFT, padx=5) thresh_spin = ttk.Spinbox(top_frame, from_=0.0, to=1.0, increment=0.01, textvariable=self.thresh_val, width=10, style='Large.TSpinbox') thresh_spin.pack(side=tk.LEFT) ttk.Button(top_frame, text="应用", style='Large.TButton', command=lambda: self.manager.set_threshold(self.thresh_val.get())).pack(side=tk.LEFT, padx=5) add_frame = ttk.Frame(right_frame) add_frame.pack(fill=tk.X, pady=5) ttk.Label(add_frame, text="类别名称:", style='Medium.TLabel').pack(side=tk.LEFT, padx=5) cls_entry = ttk.Entry(add_frame, textvariable=self.class_name, width=15, style='Large.TEntry') cls_entry.pack(side=tk.LEFT, padx=5) ttk.Button(add_frame, text="📁 添加模板图片", style='Large.TButton', command=self.add_template).pack(side=tk.LEFT, padx=5) # 相机按钮,子类实例化时再修改状态 self.camera_add_btn = ttk.Button(add_frame, text="📷 拍照添加模板", style='Large.TButton') self.camera_add_btn.pack(side=tk.LEFT, padx=5) func_frame = ttk.LabelFrame(right_frame, text="核心功能") func_frame.pack(fill=tk.X, pady=8) ttk.Button(func_frame, text="📂 选择本地图片识别", style='Large.TButton', command=self.choose_and_predict_img).pack(side=tk.LEFT, padx=5, pady=5) self.camera_btn = ttk.Button(func_frame, text="📷 相机拍照识别", style='Large.TButton') self.camera_btn.pack(side=tk.LEFT, padx=5, pady=5) self.camera_status_label = ttk.Label(func_frame, text="相机状态:未连接", foreground="red", style='Medium.TLabel') self.camera_status_label.pack(side=tk.LEFT, padx=10, pady=5) img_frame = ttk.LabelFrame(right_frame, text="图片预览") img_frame.pack(fill=tk.BOTH, expand=True, pady=5) self.img_label = ttk.Label(img_frame, text="暂无图片", style='Medium.TLabel') self.img_label.pack(pady=50) res_frame = ttk.LabelFrame(right_frame, text="识别结果") res_frame.pack(fill=tk.X, pady=5) self.result_label = ttk.Label(res_frame, text="程序启动中...", style='Title.TLabel', foreground="blue") self.result_label.pack(pady=8) self.refresh_template_list() def refresh_template_list(self): for widget in self.scrollable_frame.winfo_children(): widget.destroy() items = self.manager.get_template_list(load_thumbs=False) if not items: ttk.Label(self.scrollable_frame, text="暂无模板,请先添加", style='Medium.TLabel').pack(pady=20) return for idx, (name, count, _thumbnail) in enumerate(items, 1): if count == 0: continue item_frame = ttk.Frame(self.scrollable_frame, relief=tk.GROOVE, borderwidth=1) item_frame.pack(fill=tk.X, pady=3, padx=2) text_frame = ttk.Frame(item_frame) text_frame.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=5) ttk.Label(text_frame, text=f"{idx}. {name}", style='Title.TLabel').pack(anchor=tk.W) ttk.Label(text_frame, text=f"模板数量:{count}张", style='Small.TLabel', foreground="gray").pack(anchor=tk.W) delete_btn = ttk.Button(item_frame, text="删除", style='Large.TButton', command=lambda n=name: self.delete_template_category(n)) delete_btn.pack(side=tk.RIGHT, padx=5, pady=5) item_frame.bind("", lambda e, n=name: self.show_template_detail(n)) for child in text_frame.winfo_children(): child.bind("", lambda e, n=name: self.show_template_detail(n)) def show_template_detail(self, part_type): template_details = self.manager.get_template_details(part_type, load_thumbs=False) if not template_details: messagebox.showinfo("提示", "该类别暂无模板信息") return detail_win = tk.Toplevel(self.root) detail_win.title(f"模板详情 - {part_type}") detail_win.geometry("1400x900") self.center_window(detail_win, 1400, 900) detail_win.transient(self.root) detail_win.grab_set() detail_win._detail_photos = [] main_frame = ttk.Frame(detail_win, padding=10) main_frame.pack(fill=tk.BOTH, expand=True) ttk.Label(main_frame, text=f"类别:{part_type} (共{len(template_details)}张模板)", style='Title.TLabel').pack(pady=5) canvas = tk.Canvas(main_frame) scrollbar = ttk.Scrollbar(main_frame, orient="vertical", command=canvas.yview) scrollable_frame = ttk.Frame(canvas) scrollable_frame.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) canvas.create_window((0, 0), window=scrollable_frame, anchor="nw") canvas.configure(yscrollcommand=scrollbar.set) canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) scrollbar.pack(side=tk.RIGHT, fill=tk.Y) for idx, temp in enumerate(template_details, 1): frame = ttk.LabelFrame(scrollable_frame, text=f"模板 {idx}", padding=10) frame.pack(fill=tk.X, pady=8) info_delete_frame = ttk.Frame(frame) info_delete_frame.pack(fill=tk.X, pady=2) info_frame = ttk.Frame(info_delete_frame) info_frame.pack(side=tk.LEFT, fill=tk.X, expand=True) ttk.Label(info_frame, text=f"添加时间:{temp.get('add_time')}", style='Medium.TLabel').pack(anchor=tk.W) ttk.Label(info_frame, text=f"文件路径:{temp.get('path')}", style='Small.TLabel').pack(anchor=tk.W) delete_btn = ttk.Button(info_delete_frame, text="删除此模板", style='Large.TButton', command=lambda p=part_type, i=idx - 1: self.delete_single_template(p, i)) delete_btn.pack(side=tk.RIGHT, padx=5) try: with open(temp['path'], 'rb') as f: img = Image.open(f).convert("RGB") img.thumbnail((800, 500) if idx == 1 else (240, 180), Image.Resampling.LANCZOS) photo = ImageTk.PhotoImage(img) detail_win._detail_photos.append(photo) img_label = ttk.Label(frame, image=photo) img_label.image = photo img_label.pack(pady=5) except Exception as e: ttk.Label(frame, text=f"图片加载失败:{str(e)}", foreground="red", style='Small.TLabel').pack(pady=2) def center_window(self, window, width, height): screen_width = window.winfo_screenwidth() screen_height = window.winfo_screenheight() x = (screen_width - width) // 2 y = (screen_height - height) // 2 window.geometry(f"{width}x{height}+{x}+{y}") def delete_template_category(self, part_type): if messagebox.askyesno("确认删除", f"确定要删除类别「{part_type}」的所有模板吗?"): def delete_async(): success = self.manager.delete_template_category(part_type) self.root.after(0, lambda: self._handle_delete_result(success, part_type, is_category=True)) threading.Thread(target=delete_async, daemon=True).start() def delete_single_template(self, part_type, template_index): if messagebox.askyesno("确认删除", f"确定要删除「{part_type}」的第{template_index + 1}个模板吗?"): def delete_async(): success = self.manager.delete_template(part_type, template_index) self.root.after(0, lambda: self._handle_delete_result(success, part_type, is_category=False)) threading.Thread(target=delete_async, daemon=True).start() def _handle_delete_result(self, success, part_type, is_category): if success: if is_category: messagebox.showinfo("成功", f"已删除类别「{part_type}」的所有模板") else: messagebox.showinfo("成功", "模板删除成功") self.refresh_template_list() for widget in self.root.winfo_children(): if isinstance(widget, tk.Toplevel) and "模板详情" in widget.title(): widget.destroy() else: messagebox.showerror("错误", "删除失败") def add_template(self): cls = self.class_name.get().strip() if not cls: messagebox.showwarning("提示", "请输入类别名称!") return paths = filedialog.askopenfilenames(title="选择模板图片", filetypes=[("图片", "*.jpg *.jpeg *.png *.bmp"), ("所有文件", "*.*")]) if not paths: return def work(): try: added, total = self.manager.add_template(cls, paths) self.root.after(0, self.refresh_template_list) self.root.after(0, lambda: messagebox.showinfo("成功", f"添加完成!\n成功:{added}张 | 总数:{total}张")) except Exception as e: self.root.after(0, lambda: messagebox.showerror("错误", f"添加失败:{str(e)}")) threading.Thread(target=work, daemon=True).start() def clear_lib(self): if messagebox.askyesno("确认", "确定清空所有模板?"): self.manager.clear_all() self.refresh_template_list() self.update_pending_btn() self.result_label.config(text="模板库已清空", foreground="black") def dish_box_label(self, predict_text): """从识别文案提取框上展示的菜品名(认菜不认盘)。""" if not predict_text or predict_text == "特征提取失败": return "识别失败" if predict_text.startswith("未知"): score = predict_text.split(":")[-1] if ":" in predict_text else "" return f"未知 {score}".strip() if "相似度" in predict_text: name = predict_text.split(" 相似度")[0].strip() score = predict_text.split(":")[-1] if ":" in predict_text else "" return f"{name} {score}".strip() return predict_text def show_preview_image(self, image_path): with open(image_path, 'rb') as f: im = Image.open(f).convert("RGB") im.thumbnail((1800, 800)) photo = ImageTk.PhotoImage(im) self.img_label.config(text="", image=photo) self.img_label.image = photo def build_dish_labeled_image(self, source_image_path, boxes, dish_labels, styles=None): from pathlib import Path if isinstance(source_image_path, np.ndarray): stem = f"frame_{int(datetime.datetime.now().timestamp() * 1000)}" image_src = source_image_path else: stem = Path(source_image_path).stem image_src = source_image_path save_path = os.path.join(self.cfg.OUTPUT_DIR, f"{stem}.jpg") return self.app.visualize_with_labels( image_src, boxes, dish_labels, save_path, styles=styles, ) def choose_and_predict_img(self): file_path = filedialog.askopenfilename(title="选择识别图片", filetypes=[("图片", "*.jpg *.jpeg *.png *.bmp"), ("所有文件", "*.*")]) if not file_path: return visualize_save_path, cropped_file_paths, boxes = self.app.process_single_image(file_path) if not cropped_file_paths: messagebox.showwarning("提示", "未检测到餐盘区域") return final_result, dish_labels, unknown_count, labeled_path, dishes = self.run_plate_predict_batch( file_path, cropped_file_paths, boxes, progress_callback=lambda cur, _total: self.result_label.config( text=f"正在识别第 {cur} 张...", foreground="orange" ), ) if labeled_path: self.show_preview_image(labeled_path) elif visualize_save_path: self.show_preview_image(visualize_save_path) self.result_label.config(text=final_result or "无结果", foreground="green") self.confirm_unknown_dishes(dishes) # 以下方法由子类USB/海康分别实现 def auto_connect_camera(self): raise NotImplementedError("子类实现") def take_photo(self): raise NotImplementedError("子类实现") def camera_capture_and_add_template(self): raise NotImplementedError("子类实现") def camera_capture_and_predict(self): raise NotImplementedError("子类实现") def close_camera(self): raise NotImplementedError("子类实现")