Dish_Recognition/core/common_classifier.py

1135 lines
48 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
print("[INFO] loading templates into memory…")
self.load_templates()
print("[OK] PartTemplateManager ready")
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)
# 学菜成功:上传到云端 template/{菜名}/
self._upload_template_cloud_async(part_type, template_img_path)
added += 1
if added:
self._rebuild_sim_index()
if added > 0:
self._notify_learned_async(part_type)
return added, len(image_paths)
@staticmethod
def _upload_template_cloud_async(dish_name, image_path):
"""待学习/学菜写入本地后,异步上传到服务器 template/{菜名}/。"""
try:
from service.template_upload import upload_template_image_async
upload_template_image_async(image_path, dish_name)
except Exception as exc:
print(f'[WARN] schedule template upload failed: {exc}')
@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)
for item in prepared:
self._upload_template_cloud_async(
item.get('category_name'), item.get('image_url'),
)
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, ""
@staticmethod
def _match_filter_names():
"""模板比对后需丢弃的菜名(如手、空盘)。"""
try:
from dao.settings import DISH_MATCH_FILTER_NAMES
return {str(n).strip() for n in (DISH_MATCH_FILTER_NAMES or []) if str(n).strip()}
except Exception:
return {"手", "空盘"}
def _filtered_match_result(self, name, score, feat, top_k=None, scope="filtered"):
print(f"[match] filtered template hit: {name} score={score}")
return {
"text": f"已过滤:{name}",
"is_unknown": False,
"is_filtered": True,
"label": None,
"score": float(score) if score is not None else None,
"suggested_category": name,
"filtered_as": name,
"top_k": top_k or [],
"pending_id": None,
"feature": feat,
"match_scope": scope,
}
def predict_from_feature(self, feat, image_path=None, auto_pending=True):
"""已有特征时直接匹配(避免待学习提交时重复提特征)。
仅在「当前吃饭餐段」内:主食小库 ∪ 当前餐段菜品 合并比对(主食略优先);
不在餐段 / 无今日排餐:全部未知(不进主食、不进大库)。
均未达标 → 未知,不回退大模板库。
"""
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() and store.current_period():
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()
period_set = set(period_names or [])
filter_names = self._match_filter_names()
# 过滤类若本地有模板,纳入候选:先比中再丢弃,避免误认成其他菜
with self.lock:
lib_names = {str(k).strip() for k in (self.template_library or {}).keys() if str(k).strip()}
filter_in_lib = filter_names & lib_names
allowed = set(staple_names) | period_set | filter_in_lib
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
best_f, max_f = None, -1.0
for name, score in ranked:
sc = float(score)
if name in filter_in_lib and sc > max_f:
best_f, max_f = name, sc
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
# 过滤类达标:直接丢弃该框(不再与菜品比优先)
f_hit, _ = self._score_hit(best_f, max_f, staple_thr, staple_soft)
# 过滤类用与主食相同阈值;也可用餐段阈值取较松者
if not f_hit:
f_hit, _ = self._score_hit(best_f, max_f, small_thr, soft_thr)
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)
top_k = [{"category": n, "score": s} for n, s in (ranked or [])[:3]]
if f_hit and best_f is not None:
# 过滤类分不低于当前菜/主食命中分 → 认定应丢弃
other_best = -1.0
if st_hit:
other_best = max(other_best, float(max_st))
if p_hit:
other_best = max(other_best, float(max_p))
if float(max_f) >= other_best - float(staple_margin):
return self._filtered_match_result(
str(best_f).strip(), float(max_f), feat, top_k=top_k, scope="filtered",
)
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"
if chosen is not None:
score = float(max_st) if scope == "staple" else float(max_p)
if str(chosen).strip() in filter_names:
return self._filtered_match_result(
str(chosen).strip(), score, feat, top_k=top_k, scope=scope,
)
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_f is not None and float(max_f) > sug_score:
sug, sug_score = best_f, float(max_f)
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)
# 最接近手/空盘:整框丢弃,不进未知/待学习
if sug is not None and str(sug).strip() in filter_names:
return self._filtered_match_result(
str(sug).strip(), float(sug_score), feat, top_k=top_k, scope="period_miss",
)
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) 不在餐段 / 无排餐:不进主食、不进大库,全部未知 ----------
pending_id = None
if auto_pending and image_path:
pending_id = self.pending_store.add(image_path, feat, None, None)
return {
"text": "未知菜品 (不在当前餐段,不匹配主食/大库,已记录待学习)",
"is_unknown": True,
"label": None,
"score": None,
"suggested_category": None,
"top_k": [],
"pending_id": pending_id,
"feature": feat,
"match_scope": "no_menu",
}
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 ignore_all_pending(self):
"""忽略全部 pending:删图 + 一次写盘,避免全部忽略时卡死。"""
return self.pending_store.ignore_all_pending()
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("<Configure>", 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("<Button-1>", lambda e, n=name: self.show_template_detail(n))
for child in text_frame.winfo_children():
child.bind("<Button-1>", 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("<Configure>", 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("子类实现")