""" Hugging Face Spaces 版本 - 宠物图片分类器 使用 Gradio Blocks 自定义界面,与 Flask 前端样式一致 """ import os import json import sqlite3 import base64 from datetime import datetime from pathlib import Path from io import BytesIO import torch import torch.nn.functional as F from PIL import Image import gradio as gr import timm from torchvision import transforms # ==================== 配置 ==================== MODEL_NAME = "resnet50" NUM_CLASSES = 37 IMAGE_SIZE = 224 MEAN = [0.485, 0.456, 0.406] STD = [0.229, 0.224, 0.225] # 数据库配置 - 使用绝对路径确保在 Hugging Face Spaces 上正确工作 # 优先使用应用根目录,如果不可写则回退到 /tmp APP_DIR = Path(__file__).parent.resolve() def get_writable_base_dir(): """获取可写的基础目录 优先级: 1. /data - Hugging Face Persistent Storage(付费功能,数据持久化) 2. APP_DIR - 应用根目录(临时,容器重启后丢失) 3. /tmp - 临时目录(临时,容器重启后丢失) """ test_dirs = [ (Path("/data"), "Hugging Face Persistent Storage (持久化)"), (APP_DIR, "应用根目录 (临时)"), (Path("/tmp"), "临时目录 (临时)"), ] for base_dir, desc in test_dirs: test_path = base_dir / ".write_test" try: base_dir.mkdir(parents=True, exist_ok=True) test_path.touch() test_path.unlink() print(f"✅ 使用目录: {base_dir} - {desc}") if "临时" in desc: print(f"⚠️ 警告: 数据将在 Space 重启后丢失!") print(f" 如需持久化,请在 Settings 中启用 Persistent Storage") return base_dir except Exception as e: print(f"⚠️ 目录不可用 {base_dir}: {e}") continue # 最后回退到 /tmp print("⚠️ 回退到 /tmp 目录,数据不会持久化") return Path("/tmp") WRITABLE_BASE = get_writable_base_dir() DATABASE_DIR = WRITABLE_BASE / "database" UPLOADS_DIR = WRITABLE_BASE / "uploads" DATABASE_PATH = DATABASE_DIR / "predictions_hf.db" MAX_RECORDS = 10 # 只保存最近10次记录 print(f"📂 数据库目录: {DATABASE_DIR}") print(f"📂 上传目录: {UPLOADS_DIR}") # 37个类别名称 CLASS_NAMES = [ "Abyssinian", "american_bulldog", "american_pit_bull_terrier", "basset_hound", "beagle", "Bengal", "Birman", "Bombay", "boxer", "British_Shorthair", "chihuahua", "Egyptian_Mau", "english_cocker_spaniel", "english_setter", "german_shorthaired", "great_pyrenees", "havanese", "japanese_chin", "keeshond", "leonberger", "Maine_Coon", "miniature_pinscher", "newfoundland", "Persian", "pomeranian", "pug", "Ragdoll", "Russian_Blue", "saint_bernard", "samoyed", "scottish_terrier", "shiba_inu", "Siamese", "Sphynx", "staffordshire_bull_terrier", "wheaten_terrier", "yorkshire_terrier", ] # 类别中英文映射 CLASS_NAMES_CN = { "Abyssinian": "阿比西尼亚猫", "american_bulldog": "美国斗牛犬", "american_pit_bull_terrier": "美国比特犬", "basset_hound": "巴吉度猎犬", "beagle": "比格犬", "Bengal": "孟加拉猫", "Birman": "伯曼猫", "Bombay": "孟买猫", "boxer": "拳师犬", "British_Shorthair": "英国短毛猫", "chihuahua": "吉娃娃", "Egyptian_Mau": "埃及猫", "english_cocker_spaniel": "英国可卡犬", "english_setter": "英国塞特犬", "german_shorthaired": "德国短毛猎犬", "great_pyrenees": "大白熊犬", "havanese": "哈瓦那犬", "japanese_chin": "日本狆", "keeshond": "荷兰毛狮犬", "leonberger": "莱昂伯格犬", "Maine_Coon": "缅因猫", "miniature_pinscher": "迷你杜宾犬", "newfoundland": "纽芬兰犬", "Persian": "波斯猫", "pomeranian": "博美犬", "pug": "巴哥犬", "Ragdoll": "布偶猫", "Russian_Blue": "俄罗斯蓝猫", "saint_bernard": "圣伯纳犬", "samoyed": "萨摩耶", "scottish_terrier": "苏格兰梗", "shiba_inu": "柴犬", "Siamese": "暹罗猫", "Sphynx": "斯芬克斯猫", "staffordshire_bull_terrier": "斯塔福郡斗牛梗", "wheaten_terrier": "软毛麦色梗", "yorkshire_terrier": "约克夏梗", } # ==================== 数据库操作 ==================== def init_db(): """初始化数据库和上传目录""" print("=" * 50) print("🚀 初始化数据库和目录...") print(f"📂 DATABASE_DIR: {DATABASE_DIR}") print(f"📂 UPLOADS_DIR: {UPLOADS_DIR}") print(f"📂 DATABASE_PATH: {DATABASE_PATH}") # 创建目录 try: DATABASE_DIR.mkdir(parents=True, exist_ok=True) print(f"✅ 数据库目录已创建: {DATABASE_DIR}") print(f" 目录存在: {DATABASE_DIR.exists()}") print(f" 是否可写: {os.access(DATABASE_DIR, os.W_OK)}") except Exception as e: print(f"❌ 创建数据库目录失败: {e}") import traceback traceback.print_exc() try: UPLOADS_DIR.mkdir(parents=True, exist_ok=True) print(f"✅ 上传目录已创建: {UPLOADS_DIR}") print(f" 目录存在: {UPLOADS_DIR.exists()}") print(f" 是否可写: {os.access(UPLOADS_DIR, os.W_OK)}") except Exception as e: print(f"❌ 创建上传目录失败: {e}") import traceback traceback.print_exc() # 创建数据库 try: conn = sqlite3.connect(str(DATABASE_PATH)) cursor = conn.cursor() cursor.execute(""" CREATE TABLE IF NOT EXISTS predictions ( id INTEGER PRIMARY KEY AUTOINCREMENT, predicted_class TEXT NOT NULL, predicted_class_cn TEXT, confidence REAL NOT NULL, top5_json TEXT, image_path TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """) conn.commit() conn.close() print(f"✅ 数据库已初始化: {DATABASE_PATH}") print(f" 数据库文件存在: {DATABASE_PATH.exists()}") except Exception as e: print(f"❌ 数据库初始化失败: {e}") import traceback traceback.print_exc() print("=" * 50) # 启动时清理旧文件(保持最近10次) cleanup_old_images() def save_uploaded_image(image, record_id): """保存上传的图片""" try: # 确保上传目录存在 if not UPLOADS_DIR.exists(): print(f"⚠️ 上传目录不存在,尝试创建: {UPLOADS_DIR}") UPLOADS_DIR.mkdir(parents=True, exist_ok=True) timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") image_filename = f"{record_id}_{timestamp}.jpg" image_path = UPLOADS_DIR / image_filename # 确保是 RGB 模式 if image.mode != "RGB": image = image.convert("RGB") image.save(image_path, "JPEG", quality=85) print(f"✅ 图片已保存: {image_path}") print(f" 文件存在: {image_path.exists()}") return str(image_path) except Exception as e: print(f"❌ 保存图片失败: {e}") import traceback traceback.print_exc() return None def add_prediction_to_db(predicted_class, predicted_class_cn, confidence, top5_json, image_path=None): """添加预测记录到数据库""" conn = sqlite3.connect(str(DATABASE_PATH)) cursor = conn.cursor() cursor.execute(""" INSERT INTO predictions (predicted_class, predicted_class_cn, confidence, top5_json, image_path) VALUES (?, ?, ?, ?, ?) """, (predicted_class, predicted_class_cn, confidence, top5_json, image_path)) # 获取刚插入的记录ID record_id = cursor.lastrowid # 删除超过限制的旧记录 cursor.execute(""" DELETE FROM predictions WHERE id NOT IN ( SELECT id FROM predictions ORDER BY created_at DESC LIMIT ? ) """, (MAX_RECORDS,)) conn.commit() conn.close() return record_id def cleanup_old_images(): """清理旧的图片文件,只保留最近10次对应的图片""" try: conn = sqlite3.connect(str(DATABASE_PATH)) cursor = conn.cursor() # 获取所有保留记录的图片路径 cursor.execute("SELECT image_path FROM predictions ORDER BY created_at DESC LIMIT ?", (MAX_RECORDS,)) keep_paths = {row[0] for row in cursor.fetchall() if row[0]} conn.close() # 删除不在保留列表中的图片文件 if UPLOADS_DIR.exists(): deleted_count = 0 for img_file in UPLOADS_DIR.glob("*.jpg"): img_path_str = str(img_file) if img_path_str not in keep_paths: try: img_file.unlink() deleted_count += 1 except Exception as e: print(f"⚠️ 删除图片失败 {img_file.name}: {e}") if deleted_count > 0: print(f"🗑️ 清理了 {deleted_count} 张旧图片") except Exception as e: print(f"⚠️ 清理图片失败: {e}") def get_recent_predictions(limit=10): """获取最近的预测记录,包括图片路径""" conn = sqlite3.connect(str(DATABASE_PATH)) cursor = conn.cursor() cursor.execute(""" SELECT predicted_class, predicted_class_cn, confidence, image_path, created_at FROM predictions ORDER BY created_at DESC LIMIT ? """, (limit,)) rows = cursor.fetchall() conn.close() return rows # ==================== 模型 ==================== device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = None transform = transforms.Compose([ transforms.Resize((IMAGE_SIZE, IMAGE_SIZE)), transforms.ToTensor(), transforms.Normalize(mean=MEAN, std=STD), ]) def init_model(): global model if model is None: model_path = "models/best_model.pth" if os.path.exists(model_path): print(f"📦 加载模型: {model_path}") m = timm.create_model(MODEL_NAME, pretrained=False, num_classes=NUM_CLASSES) checkpoint = torch.load(model_path, map_location=device, weights_only=False) if "model_state_dict" in checkpoint: m.load_state_dict(checkpoint["model_state_dict"]) else: m.load_state_dict(checkpoint) m = m.to(device) m.eval() model = m print(f"✅ 模型加载完成,使用设备: {device}") else: raise FileNotFoundError(f"模型文件不存在: {model_path}") return model # ==================== 预测函数 ==================== def predict(image): if image is None: return "", "", "" m = init_model() image_rgb = image.convert("RGB") image_tensor = transform(image_rgb).unsqueeze(0).to(device) with torch.no_grad(): output = m(image_tensor) probs = F.softmax(output, dim=1) top5_probs, top5_indices = probs.topk(5, dim=1) top5_list = [] for i in range(5): idx = top5_indices[0][i].item() prob = top5_probs[0][i].item() class_name = CLASS_NAMES[idx] cn_name = CLASS_NAMES_CN.get(class_name, class_name) top5_list.append({ "class": class_name, "class_cn": cn_name, "probability": round(prob * 100, 2) }) top1_class = CLASS_NAMES[top5_indices[0][0].item()] top1_cn = CLASS_NAMES_CN.get(top1_class, top1_class) top1_confidence = round(top5_probs[0][0].item() * 100, 2) # 保存预测结果和图片到数据库 image_path = None try: print(f"📝 保存预测结果到数据库...") print(f" 数据库路径: {DATABASE_PATH}") print(f" 数据库存在: {DATABASE_PATH.exists()}") # 确保数据库目录存在 if not DATABASE_DIR.exists(): print(f"⚠️ 数据库目录不存在,尝试创建: {DATABASE_DIR}") DATABASE_DIR.mkdir(parents=True, exist_ok=True) # 先插入记录获取ID(不清理,等保存图片后再清理) conn = sqlite3.connect(str(DATABASE_PATH)) cursor = conn.cursor() cursor.execute(""" INSERT INTO predictions (predicted_class, predicted_class_cn, confidence, top5_json, image_path) VALUES (?, ?, ?, ?, ?) """, (top1_class, top1_cn, top1_confidence, json.dumps(top5_list, ensure_ascii=False), None)) record_id = cursor.lastrowid conn.commit() print(f"✅ 预测记录已插入,ID: {record_id}") # 保存图片 image_path = save_uploaded_image(image_rgb, record_id) # 更新数据库记录中的图片路径 if image_path: cursor.execute("UPDATE predictions SET image_path = ? WHERE id = ?", (image_path, record_id)) conn.commit() print(f"✅ 图片路径已更新到数据库") # 删除超过限制的旧记录(在同一个事务中) cursor.execute(""" DELETE FROM predictions WHERE id NOT IN ( SELECT id FROM predictions ORDER BY created_at DESC LIMIT ? ) """, (MAX_RECORDS,)) conn.commit() conn.close() # 清理旧的图片文件 cleanup_old_images() except Exception as e: print(f"❌ 保存失败: {e}") import traceback traceback.print_exc() # 构建结果显示 result_class = f"{top1_class} ({top1_cn})" result_confidence = f"{top1_confidence}%" # 构建 Top5 HTML top5_html = "" for i, item in enumerate(top5_list): top5_html += f'''
{i+1} {item["class"]} ({item["class_cn"]}) {item["probability"]}%
''' return result_class, result_confidence, top5_html def image_to_base64(image_path): """将图片转换为 base64 编码""" try: if image_path and Path(image_path).exists(): with open(image_path, "rb") as img_file: img_data = img_file.read() img_base64 = base64.b64encode(img_data).decode("utf-8") return f"data:image/jpeg;base64,{img_base64}" except Exception as e: print(f"⚠️ 转换图片失败: {e}") return None def get_history_html(): """生成历史记录HTML,包含图片""" rows = get_recent_predictions(MAX_RECORDS) if not rows: return '''
📭

暂无历史记录

''' html = "" for row in rows: pred_class, pred_cn, confidence, image_path, created_at = row # 处理图片 - 转换为 base64 编码 image_html = "" if image_path: img_base64 = image_to_base64(image_path) if img_base64: image_html = f'预测图片' else: image_html = '
📷
' else: image_html = '
📷
' html += f'''
{image_html}
{pred_class} ({pred_cn})
{created_at}
{confidence}%
''' return html # ==================== 自定义 CSS ==================== custom_css = """ .gradio-container { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important; min-height: 100vh; } .main-header { text-align: center; color: white; padding: 20px 0; } .main-header h1 { font-size: 2.5rem; margin-bottom: 10px; text-shadow: 0 2px 10px rgba(0,0,0,0.2); } .main-header p { font-size: 1.1rem; opacity: 0.9; } .card { background: white !important; border-radius: 20px !important; padding: 25px !important; box-shadow: 0 15px 35px rgba(0,0,0,0.1) !important; } .card h2 { color: #333; margin-bottom: 15px; font-size: 1.4rem; } .prediction-main { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 16px; padding: 25px; text-align: center; color: white; margin: 15px 0; } .prediction-class { font-size: 1.6rem; font-weight: bold; margin-bottom: 10px; } .prediction-confidence { font-size: 2.5rem; font-weight: bold; text-shadow: 0 2px 10px rgba(0,0,0,0.2); } .prediction-label { font-size: 0.9rem; opacity: 0.9; margin-top: 5px; } .top5-item { display: flex; justify-content: space-between; align-items: center; padding: 10px 12px; border-radius: 10px; margin-bottom: 8px; background: #f8f9fa; } .top5-rank { width: 26px; height: 26px; background: #667eea; color: white; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-weight: bold; font-size: 0.85rem; margin-right: 10px; } .top5-name { flex: 1; font-weight: 500; color: #333; } .top5-prob { font-weight: bold; color: #667eea; } .top5-bar { width: 100%; height: 4px; background: #eee; border-radius: 2px; margin-bottom: 10px; overflow: hidden; } .top5-bar-fill { height: 100%; background: linear-gradient(90deg, #667eea, #764ba2); border-radius: 2px; } .history-item { display: flex; align-items: center; padding: 12px; border-radius: 10px; margin-bottom: 8px; background: #f8f9fa; gap: 12px; } .history-image { width: 60px; height: 60px; object-fit: cover; border-radius: 8px; border: 2px solid #e0e0e0; } .history-image-placeholder { width: 60px; height: 60px; display: flex; align-items: center; justify-content: center; background: #e0e0e0; border-radius: 8px; font-size: 24px; } .history-info { flex: 1; } .history-class { font-weight: bold; color: #333; margin-bottom: 3px; } .history-meta { font-size: 0.8rem; color: #999; } .history-confidence { font-weight: bold; color: #667eea; font-size: 1.1rem; } .empty-state { text-align: center; padding: 30px; color: #999; } .empty-icon { font-size: 40px; margin-bottom: 10px; } """ # ==================== Gradio 界面 ==================== # 初始化数据库和目录 init_db() with gr.Blocks(title="🐾 宠物图片分类器", css=custom_css) as demo: gr.HTML("""

🐾 宠物图片分类器

基于 Oxford-IIIT Pet 数据集训练,支持 37 种宠物分类

""") with gr.Row(): # 左侧:上传和结果 with gr.Column(scale=1): with gr.Group(elem_classes="card"): gr.HTML("

📷 上传图片

") image_input = gr.Image(type="pil", height=300) predict_btn = gr.Button("🔍 开始识别", variant="primary", size="lg") with gr.Column(elem_classes="card", visible=False) as result_container: gr.HTML("

🎯 识别结果

") result_html = gr.HTML() # 右侧:历史记录 with gr.Column(scale=1): with gr.Group(elem_classes="card"): gr.HTML("

📜 历史记录

") history_html = gr.HTML(value=get_history_html()) refresh_btn = gr.Button("🔄 刷新", size="sm") def on_predict(image): if image is None: return gr.update(visible=False), "", get_history_html() result_class, result_confidence, top5_html = predict(image) result_display = f'''
{result_class}
{result_confidence}
置信度

Top 5 预测

{top5_html} ''' return gr.update(visible=True), result_display, get_history_html() def on_refresh(): return get_history_html() predict_btn.click( fn=on_predict, inputs=[image_input], outputs=[result_container, result_html, history_html] ) refresh_btn.click(fn=on_refresh, outputs=[history_html]) # ==================== 启动 ==================== if __name__ == "__main__": demo.launch(server_name="0.0.0.0", server_port=7860)