# coding=utf-8 """一键导入模板:文件夹内图片文件名 = 菜名。 用法(在项目根目录):: python tools/import_templates_by_name.py "D:\\菜品模板" python tools/import_templates_by_name.py "D:\\菜品模板" --dry-run python tools/import_templates_by_name.py "D:\\菜品模板" --local-only 说明: - 旧版逐张 add_template 时,每张都会从 MySQL 全量/增量同步,容易把本地刚写入的冲掉 - 本脚本改为批量写入,最后只同步一次 - --local-only:只写本机 SQLite(并入上传队列),不立刻连 MySQL 拉取 """ from __future__ import annotations import argparse import os import sys from pathlib import Path _ROOT = Path(__file__).resolve().parents[1] if str(_ROOT) not in sys.path: sys.path.insert(0, str(_ROOT)) IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".bmp", ".webp"} def collect_images(folder: Path, recursive: bool) -> list[Path]: files = folder.rglob("*") if recursive else folder.iterdir() out = [p for p in files if p.is_file() and p.suffix.lower() in IMAGE_EXTS] out.sort(key=lambda x: x.name) return out def main(): parser = argparse.ArgumentParser(description="按文件名(菜名)批量导入模板") parser.add_argument("folder", help="模板图片文件夹路径") parser.add_argument("--recursive", "-r", action="store_true", help="包含子文件夹") parser.add_argument("--dry-run", action="store_true", help="只列出,不写入") parser.add_argument( "--local-only", action="store_true", help="强制只写本地库(推荐客户机 MySQL 不通时使用)", ) parser.add_argument( "--fail-log", default="import_fail.log", help="失败明细日志路径(相对项目根或绝对路径)", ) args = parser.parse_args() folder = Path(args.folder).expanduser().resolve() if not folder.is_dir(): print(f"[ERR] 文件夹不存在: {folder}") sys.exit(1) images = collect_images(folder, args.recursive) if not images: print(f"[ERR] 未找到图片: {folder}") sys.exit(1) pairs = [] for p in images: name = p.stem.strip() if not name: print(f"[WARN] 跳过空文件名: {p}") continue pairs.append((name, str(p))) print(f"文件夹: {folder}") print(f"图片数: {len(images)} | 待导入: {len(pairs)}") if args.dry_run: from collections import Counter c = Counter(n for n, _ in pairs) print(f"菜品种类: {len(c)}") for name, n in c.most_common(20): print(f" {name}: {n} 张") if len(c) > 20: print(f" ... 其余 {len(c) - 20} 个菜品") print("[dry-run] 未写入。") return if args.local_only: # 让 is_server_available() 在本进程内始终失败 → 走本地插入 + 上传队列 import dao.database as db db.is_server_available = lambda: False # type: ignore print("[INFO] --local-only:只写本机 SQLite,不拉取 MySQL") print("加载特征提取器 / 模板库(首次可能较慢)…") from core.common_classifier import PartTemplateManager, TEMPLATE_SAVE_DIR from dao.database import local_session_scope from models.Global_templates import GlobalTemplate from models.Dish_categories import DishCategory from sqlalchemy import func manager = PartTemplateManager() def progress(i, total, name, path): if i == 1 or i % 20 == 0 or i == total: print(f" 提特征 [{i}/{total}] {name}", flush=True) print("开始批量提特征并写入…") ok, failures = manager.add_templates_batch(pairs, progress_callback=progress) fail_log = Path(args.fail_log) if not fail_log.is_absolute(): fail_log = _ROOT / fail_log if failures: with open(fail_log, "w", encoding="utf-8") as f: for path, reason in failures: f.write(f"{reason}\t{path}\n") print(f"[WARN] 失败 {len(failures)} 张,明细: {fail_log}") with local_session_scope() as session: n_cat = ( session.query(func.count(DishCategory.id)) .filter(DishCategory.is_active == 1) .scalar() ) or 0 n_tpl = ( session.query(func.count(GlobalTemplate.id)) .filter(GlobalTemplate.is_active == 1) .scalar() ) or 0 print() print("========== 完成 ==========") print(f"本次成功写入: {ok}") print(f"本次失败: {len(failures)}") print(f"本地库当前: {n_cat} 个菜品 / {n_tpl} 张模板") print(f"模板图片目录: {TEMPLATE_SAVE_DIR}") if n_tpl < len(pairs) * 0.5: print() print("[提示] 模板数远小于图片数,常见原因:") print(" 1) 特征提取失败(看 import_fail.log)") print(" 2) 打开识别软件后 MySQL 全量同步把本地冲掉了") print(" → 请用本脚本加 --local-only 重新导入,导入后先别连总库同步") print("请重启识别程序查看模板库。") if __name__ == "__main__": main()