diff --git a/fastAPI_tarot.py b/fastAPI_tarot.py
index 8b8b050..3ac180e 100644
--- a/fastAPI_tarot.py
+++ b/fastAPI_tarot.py
@@ -20,6 +20,8 @@ import json
import traceback
import re
import asyncio
+import shutil
+from datetime import datetime
from typing import Optional, List, Dict, Any
from contextlib import asynccontextmanager
@@ -34,10 +36,10 @@ import matplotlib.pyplot as plt
from PIL import Image
# FastAPI Imports
-from fastapi import FastAPI, File, UploadFile, Form, HTTPException, Request, Depends, status, APIRouter
+from fastapi import FastAPI, File, UploadFile, Form, HTTPException, Request, Depends, status, APIRouter, Cookie
from fastapi.security import APIKeyHeader
from fastapi.staticfiles import StaticFiles
-from fastapi.responses import JSONResponse
+from fastapi.responses import JSONResponse, HTMLResponse, Response
# Dashscope (Aliyun Qwen) Imports
import dashscope
@@ -62,6 +64,10 @@ os.makedirs(RESULT_IMAGE_DIR, exist_ok=True)
VALID_API_KEY = "123quant-speed"
API_KEY_HEADER_NAME = "X-API-Key"
+# Admin Config
+ADMIN_PASSWORD = "admin_secure_password" # 可以根据需求修改
+HISTORY_FILE = "history.json"
+
# Dashscope (Qwen-VL) 配置
dashscope.api_key = 'sk-ce2404f55f744a1987d5ece61c6bac58'
QWEN_MODEL = 'qwen-vl-max'
@@ -219,6 +225,25 @@ def is_english(text: str) -> bool:
return False
return True
+def append_to_history(req_type: str, prompt: str, status: str, result_path: str = None, details: str = ""):
+ """
+ 记录请求历史到 history.json
+ """
+ record = {
+ "timestamp": time.time(),
+ "type": req_type,
+ "prompt": prompt,
+ "status": status,
+ "result_path": result_path,
+ "details": details
+ }
+ try:
+ with open(HISTORY_FILE, "a", encoding="utf-8") as f:
+ f.write(json.dumps(record, ensure_ascii=False) + "\n")
+ except Exception as e:
+ print(f"Failed to write history: {e}")
+
+
def translate_to_sam3_prompt(text: str) -> str:
"""
使用 Qwen 模型将中文提示词翻译为英文
@@ -640,6 +665,7 @@ async def segment(
elif image_url:
image = load_image_from_url(image_url)
except Exception as e:
+ append_to_history("general", prompt, "failed", details=f"Image Load Error: {str(e)}")
raise HTTPException(status_code=400, detail=f"图片解析失败: {str(e)}")
processor = request.app.state.processor
@@ -669,6 +695,7 @@ async def segment(
processor.confidence_threshold = original_confidence
except Exception as e:
+ append_to_history("general", prompt, "failed", details=f"Inference Error: {str(e)}")
raise HTTPException(status_code=500, detail=f"模型推理错误: {str(e)}")
# 4. 结果可视化与保存
@@ -681,6 +708,7 @@ async def segment(
else:
filename = generate_and_save_result(image, inference_state)
except Exception as e:
+ append_to_history("general", prompt, "failed", details=f"Save Error: {str(e)}")
raise HTTPException(status_code=500, detail=f"绘图保存错误: {str(e)}")
file_url = request.url_for("static", path=f"results/{filename}")
@@ -712,6 +740,8 @@ async def segment(
})
except Exception as e:
print(f"Error saving segments: {e}")
+ # Don't fail the whole request just for this part, but log it? Or fail? Usually fail.
+ append_to_history("general", prompt, "partial_success", result_path=f"results/{filename}", details="Segments save failed")
raise HTTPException(status_code=500, detail=f"保存分割图片失败: {str(e)}")
response_content = {
@@ -724,6 +754,7 @@ async def segment(
if save_segment_images:
response_content["segmented_images"] = saved_segments_info
+ append_to_history("general", prompt, "success", result_path=f"results/{filename}", details=f"Detected: {len(masks)}")
return JSONResponse(content=response_content)
# ------------------------------------------
@@ -753,6 +784,7 @@ async def segment_tarot(
elif image_url:
image = load_image_from_url(image_url)
except Exception as e:
+ append_to_history("tarot", f"expected: {expected_count}", "failed", details=f"Image Load Error: {str(e)}")
raise HTTPException(status_code=400, detail=f"图片解析失败: {str(e)}")
processor = request.app.state.processor
@@ -763,6 +795,7 @@ async def segment_tarot(
output = processor.set_text_prompt(state=inference_state, prompt="tarot card")
masks, boxes, scores = output["masks"], output["boxes"], output["scores"]
except Exception as e:
+ append_to_history("tarot", f"expected: {expected_count}", "failed", details=f"Inference Error: {str(e)}")
raise HTTPException(status_code=500, detail=f"模型推理错误: {str(e)}")
# 核心逻辑:判断数量
@@ -781,6 +814,7 @@ async def segment_tarot(
except:
file_url = None
+ append_to_history("tarot", f"expected: {expected_count}", "failed", result_path=f"results/{request_id}/{filename}" if file_url else None, details=f"Detected {detected_count} cards, expected {expected_count}")
return JSONResponse(
status_code=400,
content={
@@ -795,6 +829,7 @@ async def segment_tarot(
try:
saved_objects = crop_and_save_objects(image, masks, boxes, output_dir=output_dir)
except Exception as e:
+ append_to_history("tarot", f"expected: {expected_count}", "failed", details=f"Crop Error: {str(e)}")
raise HTTPException(status_code=500, detail=f"抠图处理错误: {str(e)}")
# 生成 URL 列表和元数据
@@ -816,6 +851,7 @@ async def segment_tarot(
except:
main_file_url = None
+ append_to_history("tarot", f"expected: {expected_count}", "success", result_path=f"results/{request_id}/{main_filename}" if main_file_url else None, details=f"Successfully segmented {expected_count} cards")
return JSONResponse(content={
"status": "success",
"message": f"成功识别并分割 {expected_count} 张塔罗牌 (已执行透视矫正)",
@@ -847,6 +883,7 @@ async def recognize_tarot(
elif image_url:
image = load_image_from_url(image_url)
except Exception as e:
+ append_to_history("tarot-recognize", f"expected: {expected_count}", "failed", details=f"Image Load Error: {str(e)}")
raise HTTPException(status_code=400, detail=f"图片解析失败: {str(e)}")
processor = request.app.state.processor
@@ -856,6 +893,7 @@ async def recognize_tarot(
output = processor.set_text_prompt(state=inference_state, prompt="tarot card")
masks, boxes, scores = output["masks"], output["boxes"], output["scores"]
except Exception as e:
+ append_to_history("tarot-recognize", f"expected: {expected_count}", "failed", details=f"Inference Error: {str(e)}")
raise HTTPException(status_code=500, detail=f"模型推理错误: {str(e)}")
detected_count = len(masks)
@@ -883,6 +921,7 @@ async def recognize_tarot(
spread_info = recognize_spread_with_qwen(temp_raw_path)
if detected_count != expected_count:
+ append_to_history("tarot-recognize", f"expected: {expected_count}", "failed", result_path=f"results/{request_id}/{main_filename}" if main_file_url else None, details=f"Detected {detected_count}, expected {expected_count}")
return JSONResponse(
status_code=400,
content={
@@ -898,6 +937,7 @@ async def recognize_tarot(
try:
saved_objects = crop_and_save_objects(image, masks, boxes, output_dir=output_dir)
except Exception as e:
+ append_to_history("tarot-recognize", f"expected: {expected_count}", "failed", details=f"Crop Error: {str(e)}")
raise HTTPException(status_code=500, detail=f"抠图处理错误: {str(e)}")
# 遍历每张卡片进行识别
@@ -918,6 +958,7 @@ async def recognize_tarot(
"note": obj["note"]
})
+ append_to_history("tarot-recognize", f"expected: {expected_count}", "success", result_path=f"results/{request_id}/{main_filename}" if main_file_url else None, details=f"Spread: {spread_info.get('spread_name', 'Unknown')}")
return JSONResponse(content={
"status": "success",
"message": f"成功识别并分割 {expected_count} 张塔罗牌 (含Qwen识别结果)",
@@ -967,6 +1008,7 @@ async def segment_face(
elif image_url:
image = load_image_from_url(image_url)
except Exception as e:
+ append_to_history("face", prompt, "failed", details=f"Image Load Error: {str(e)}")
raise HTTPException(status_code=400, detail=f"图片解析失败: {str(e)}")
processor = request.app.state.processor
@@ -982,6 +1024,7 @@ async def segment_face(
except Exception as e:
import traceback
traceback.print_exc()
+ append_to_history("face", prompt, "failed", details=f"Process Error: {str(e)}")
raise HTTPException(status_code=500, detail=f"处理失败: {str(e)}")
# 补全 URL
@@ -993,11 +1036,197 @@ async def segment_face(
for item in result["results"]:
relative_path = item.pop("relative_path")
item["url"] = str(request.url_for("static", path=relative_path))
-
+
+ append_to_history("face", prompt, result["status"], details=f"Results: {len(result.get('results', []))}")
return JSONResponse(content=result)
# ==========================================
-# 8. Main Entry Point (启动入口)
+# 9. Admin Management APIs (管理后台接口)
+# ==========================================
+
+@app.get("/admin", include_in_schema=False)
+async def admin_page():
+ """
+ Serve the admin HTML page
+ """
+ # 检查 static/admin.html 是否存在,不存在则返回错误或简易页面
+ admin_html_path = os.path.join(STATIC_DIR, "admin.html")
+ if os.path.exists(admin_html_path):
+ with open(admin_html_path, "r", encoding="utf-8") as f:
+ content = f.read()
+ return HTMLResponse(content=content)
+ else:
+ return HTMLResponse(content="
Admin page not found
", status_code=404)
+
+@app.post("/admin/login", include_in_schema=False)
+async def admin_login(password: str = Form(...)):
+ """
+ Simple Admin Login
+ """
+ if password == ADMIN_PASSWORD:
+ content = {"status": "success", "message": "Logged in"}
+ response = JSONResponse(content=content)
+ # Set a simple cookie
+ response.set_cookie(key="admin_token", value="logged_in", httponly=True)
+ return response
+ else:
+ return JSONResponse(status_code=401, content={"status": "error", "message": "Invalid password"})
+
+async def verify_admin(request: Request):
+ # Check cookie or header
+ token = request.cookies.get("admin_token")
+ # Also allow local dev without strict check if needed, but here we enforce password
+ if token != "logged_in":
+ raise HTTPException(status_code=401, detail="Unauthorized")
+
+@app.get("/admin/api/history", dependencies=[Depends(verify_admin)])
+async def get_history():
+ """
+ Get request history
+ """
+ if not os.path.exists(HISTORY_FILE):
+ return []
+
+ records = []
+ try:
+ with open(HISTORY_FILE, "r", encoding="utf-8") as f:
+ for line in f:
+ if line.strip():
+ try:
+ records.append(json.loads(line))
+ except:
+ pass
+ # Limit to last 100 records
+ return records[-100:]
+ except Exception as e:
+ return {"error": str(e)}
+
+@app.get("/admin/api/files", dependencies=[Depends(verify_admin)])
+async def list_files(path: str = ""):
+ """
+ List files in static/results
+ """
+ # Security check: prevent directory traversal
+ if ".." in path or path.startswith("/"):
+ raise HTTPException(status_code=400, detail="Invalid path")
+
+ target_dir = os.path.join(RESULT_IMAGE_DIR, path)
+ if not os.path.exists(target_dir):
+ return []
+
+ items = []
+ try:
+ for entry in os.scandir(target_dir):
+ is_dir = entry.is_dir()
+ item = {
+ "name": entry.name,
+ "is_dir": is_dir,
+ "path": os.path.join(path, entry.name),
+ "mtime": entry.stat().st_mtime
+ }
+ if is_dir:
+ try:
+ item["count"] = len(os.listdir(entry.path))
+ except:
+ item["count"] = 0
+ else:
+ item["size"] = entry.stat().st_size
+ # Construct URL
+ # Assuming static mount is /static
+ # path is relative to results/
+ # so url is /static/results/path/name
+ rel_path = os.path.join("results", path, entry.name)
+ item["url"] = f"/static/{rel_path}"
+
+ items.append(item)
+
+ # Sort: Directories first, then by time (newest first)
+ items.sort(key=lambda x: (not x["is_dir"], -x["mtime"]))
+ return items
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+
+@app.delete("/admin/api/files/{file_path:path}", dependencies=[Depends(verify_admin)])
+async def delete_file(file_path: str):
+ """
+ Delete file or directory
+ """
+ # Security check
+ if ".." in file_path:
+ raise HTTPException(status_code=400, detail="Invalid path")
+
+ # file_path is relative to static/results/ (or passed as full relative path from API)
+ # The API is called with relative path from current view
+
+ target_path = os.path.join(RESULT_IMAGE_DIR, file_path)
+
+ if not os.path.exists(target_path):
+ raise HTTPException(status_code=404, detail="Not found")
+
+ try:
+ if os.path.isdir(target_path):
+ shutil.rmtree(target_path)
+ else:
+ os.remove(target_path)
+ return {"status": "success", "message": f"Deleted {file_path}"}
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+
+@app.post("/admin/api/cleanup", dependencies=[Depends(verify_admin)])
+async def trigger_cleanup():
+ """
+ Manually trigger cleanup
+ """
+ try:
+ # Re-use logic from cleanup_old_files but force it for all files > 0 seconds if we want deep clean?
+ # Or just use the standard lifetime. Let's use standard lifetime but run it now.
+ lifetime = int(os.getenv("FILE_LIFETIME_SECONDS", "3600"))
+
+ count = 0
+ current_time = time.time()
+ for root, dirs, files in os.walk(RESULT_IMAGE_DIR):
+ for file in files:
+ file_path = os.path.join(root, file)
+ try:
+ file_mtime = os.path.getmtime(file_path)
+ if current_time - file_mtime > lifetime:
+ os.remove(file_path)
+ count += 1
+ except:
+ pass
+
+ # Cleanup empty dirs
+ for root, dirs, files in os.walk(RESULT_IMAGE_DIR, topdown=False):
+ for dir in dirs:
+ dir_path = os.path.join(root, dir)
+ try:
+ if not os.listdir(dir_path):
+ os.rmdir(dir_path)
+ except:
+ pass
+
+ return {"status": "success", "message": f"Cleaned {count} files"}
+ except Exception as e:
+ return {"status": "error", "message": str(e)}
+
+@app.get("/admin/api/config", dependencies=[Depends(verify_admin)])
+async def get_config(request: Request):
+ """
+ Get system config info
+ """
+ device = "Unknown"
+ if hasattr(request.app.state, "device"):
+ device = str(request.app.state.device)
+
+ return {
+ "device": device,
+ "cleanup_enabled": os.getenv("AUTO_CLEANUP_ENABLED"),
+ "file_lifetime": os.getenv("FILE_LIFETIME_SECONDS"),
+ "cleanup_interval": os.getenv("CLEANUP_INTERVAL_SECONDS")
+ }
+
+# ==========================================
+# 10. Main Entry Point (启动入口)
# ==========================================
if __name__ == "__main__":
diff --git a/run_monitor.sh b/run_monitor.sh
new file mode 100755
index 0000000..7b50bdf
--- /dev/null
+++ b/run_monitor.sh
@@ -0,0 +1,164 @@
+#!/bin/bash
+
+# ==============================================================================
+# SAM3 项目启动与监控脚本
+# 功能:启动 Python FastAPI 服务,并持续监控健康状态
+# 作者:Trae AI
+# 日期:2026-02-17
+# ==============================================================================
+
+# 配置部分
+PROJECT_DIR="/home/quant/data/dev/sam3" # 项目根目录
+SCRIPT_NAME="fastAPI_tarot.py" # Python 启动脚本
+LOG_FILE="${PROJECT_DIR}/log/monitor.log" # 监控日志文件
+APP_LOG_FILE="${PROJECT_DIR}/log/app.log" # 应用输出日志文件
+PORT=55600 # 服务端口
+CHECK_INTERVAL=5 # 检查间隔(秒)
+MAX_FAILURES=3 # 最大连续失败次数,超过则重启
+STARTUP_TIMEOUT=300 # 启动超时时间(秒),等待模型加载
+PYTHON_CMD="python" # Python 命令,根据环境可能是 python3
+
+# 切换到项目目录
+cd "$PROJECT_DIR" || exit 1
+
+# 初始化变量
+APP_PID=0
+FAIL_COUNT=0
+
+# ==============================================================================
+# 函数:记录日志 (log_message)
+# 功能:将带有时间戳的信息写入日志文件并输出到控制台
+# 参数:$1 - 日志内容
+# ==============================================================================
+log_message() {
+ local timestamp=$(date "+%Y-%m-%d %H:%M:%S")
+ echo "[$timestamp] $1" | tee -a "$LOG_FILE"
+}
+
+# ==============================================================================
+# 函数:启动应用 (start_app)
+# 功能:启动 FastAPI 服务,并记录 PID
+# ==============================================================================
+start_app() {
+ log_message "正在启动项目: $SCRIPT_NAME ..."
+ log_message "应用日志将输出到: $APP_LOG_FILE"
+
+ # 后台启动 Python 脚本,将 stdout 和 stderr 重定向到日志
+ # 使用 -u 参数启用无缓冲输出,确保日志实时更新
+ nohup $PYTHON_CMD -u "$SCRIPT_NAME" > "$APP_LOG_FILE" 2>&1 &
+
+ APP_PID=$!
+ log_message "项目已启动,PID: $APP_PID"
+
+ log_message "正在等待服务初始化 (最多等待 ${STARTUP_TIMEOUT} 秒)..."
+
+ # 循环检查服务是否就绪
+ local elapsed=0
+ while [ $elapsed -lt $STARTUP_TIMEOUT ]; do
+ # 检查进程是否还活着
+ if ! kill -0 $APP_PID 2>/dev/null; then
+ log_message "错误: 进程在启动过程中退出。请检查应用日志。"
+ return 1
+ fi
+
+ # 检查端口响应
+ HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:$PORT/docs")
+ if [ "$HTTP_CODE" == "200" ]; then
+ log_message "服务启动成功!"
+ return 0
+ fi
+
+ sleep 5
+ elapsed=$((elapsed + 5))
+
+ # 每30秒打印一次等待日志
+ if [ $((elapsed % 30)) -eq 0 ]; then
+ log_message "仍在等待服务启动... (已耗时 ${elapsed} 秒)"
+ fi
+ done
+
+ log_message "错误: 服务启动超时 (${STARTUP_TIMEOUT} 秒)。正在终止进程..."
+ kill -9 $APP_PID 2>/dev/null
+ return 1
+}
+
+# ==============================================================================
+# 函数:停止应用 (stop_app)
+# 功能:通过 PID 停止应用,如果失败则强制杀死
+# ==============================================================================
+stop_app() {
+ if [ $APP_PID -gt 0 ]; then
+ log_message "正在停止项目 (PID: $APP_PID)..."
+ kill $APP_PID 2>/dev/null
+
+ # 等待进程结束
+ for i in {1..5}; do
+ if ! kill -0 $APP_PID 2>/dev/null; then
+ log_message "项目已停止"
+ return
+ fi
+ sleep 1
+ done
+
+ # 如果还在运行,强制杀死
+ log_message "项目未响应,正在强制终止..."
+ kill -9 $APP_PID 2>/dev/null
+ fi
+}
+
+# ==============================================================================
+# 函数:检查健康状态 (check_health)
+# 功能:检查进程是否存在以及端口是否响应
+# 返回:0 (正常) / 1 (异常)
+# ==============================================================================
+check_health() {
+ # 1. 检查进程是否存在
+ if ! kill -0 $APP_PID 2>/dev/null; then
+ log_message "警告: 进程 $APP_PID 不存在"
+ return 1
+ fi
+
+ # 2. 检查端口响应 (请求 /docs 接口)
+ # 使用 curl 获取 HTTP 状态码
+ HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:$PORT/docs")
+
+ if [ "$HTTP_CODE" == "200" ]; then
+ return 0
+ else
+ log_message "警告: 健康检查失败,HTTP 状态码: $HTTP_CODE"
+ return 1
+ fi
+}
+
+# ==============================================================================
+# 主循环
+# ==============================================================================
+
+# 初始启动
+start_app
+
+while true; do
+ if check_health; then
+ # 健康检查通过
+ FAIL_COUNT=0
+ # log_message "健康检查通过" # 可选:为了减少日志量,可以注释掉这行
+ else
+ # 健康检查失败
+ ((FAIL_COUNT++))
+ log_message "健康检查失败 ($FAIL_COUNT/$MAX_FAILURES)"
+
+ if [ $FAIL_COUNT -ge $MAX_FAILURES ]; then
+ log_message "错误: 连续检测失败次数过多,准备重启项目..."
+ stop_app
+ start_app
+ FAIL_COUNT=0
+ elif ! kill -0 $APP_PID 2>/dev/null; then
+ # 如果进程直接没了,立即重启
+ log_message "错误: 进程意外退出,立即重启..."
+ start_app
+ FAIL_COUNT=0
+ fi
+ fi
+
+ sleep $CHECK_INTERVAL
+done
diff --git a/static/admin.html b/static/admin.html
new file mode 100644
index 0000000..8048226
--- /dev/null
+++ b/static/admin.html
@@ -0,0 +1,399 @@
+
+
+
+
+
+ SAM3 项目管理后台
+
+
+
+
+
+
+
+
+
+
+
+
SAM3 管理后台
+
+
+
+
+
+
{{ loginError }}
+
+
+
+
+
+
+
+
+
+
+
+
+
最近识别记录
+
+
+
+
+
+
+ | 时间 |
+ 类型 |
+ Prompt / 详情 |
+ 状态 |
+ 操作 |
+
+
+
+
+ |
+ {{ formatDate(record.timestamp) }}
+ |
+
+
+ {{ record.type }}
+
+ |
+
+ {{ record.details }}
+ |
+
+
+ {{ record.status }}
+
+ |
+
+
+ |
+
+
+ |
+ 暂无记录
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
文件资源管理 (static/results)
+
+
+
+
+ 当前路径: /static/results/{{ currentPath }}
+
+
+
+
+
+
+
+
+
+
+ {{ file.name }}
+ {{ file.count }} 项
+
+
+
+
![]()
+
{{ file.name }}
+
+
+
+
+ {{ file.name }}
+
+
+
+
+
+
+
+ 此目录下没有文件
+
+
+
+
+
+
+
系统设置
+
+
+
+
自动清理配置
+
+
+ 状态
+ 运行中
+
+
+ 文件保留时长
+ 3600 秒 (1小时)
+
+
+ 检查间隔
+ 600 秒 (10分钟)
+
+
+
+
将删除所有超过保留时长的文件
+
+
+
+
+
+
系统信息
+
+
+ 模型
+ SAM3
+
+
+ 多模态模型
+ Qwen-VL-Max
+
+
+ 设备
+ {{ deviceInfo }}
+
+
+
+
+
+
+
+
+
+
+
+
![]()
+
+
+
+
+
+
+
+