1135 lines
48 KiB
Python
1135 lines
48 KiB
Python
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("子类实现") |