qwen3.5 优化

This commit is contained in:
2026-02-18 14:38:12 +08:00
parent 765a0aebdc
commit aee6f8804f
28 changed files with 628 additions and 55 deletions

View File

@@ -22,6 +22,7 @@ import re
import asyncio
import shutil
import subprocess
import ast
from datetime import datetime
from typing import Optional, List, Dict, Any
from contextlib import asynccontextmanager
@@ -288,6 +289,35 @@ def append_to_history(req_type: str, prompt: str, status: str, result_path: str
print(f"Failed to write history: {e}")
def extract_json_from_response(text: str) -> dict:
"""
Robustly extract JSON from text, handling:
1. Markdown code blocks (```json ... ```)
2. Single quotes (Python dict style) via ast.literal_eval
"""
try:
# 1. Try to find JSON block
json_match = re.search(r'```json\s*(.*?)\s*```', text, re.DOTALL)
if json_match:
clean_text = json_match.group(1).strip()
else:
# Try to find { ... } block if no markdown
match = re.search(r'\{.*\}', text, re.DOTALL)
if match:
clean_text = match.group(0).strip()
else:
clean_text = text.strip()
# 2. Try standard JSON
return json.loads(clean_text)
except Exception as e1:
# 3. Try ast.literal_eval for single quotes
try:
return ast.literal_eval(clean_text)
except Exception as e2:
# 4. Fail
raise ValueError(f"Could not parse JSON: {e1} | {e2} | Content: {text[:100]}...")
def translate_to_sam3_prompt(text: str) -> str:
"""
使用 Qwen 模型将中文提示词翻译为英文
@@ -567,13 +597,13 @@ def recognize_card_with_qwen(image_path: str) -> dict:
if response.status_code == 200:
content = response.output.choices[0].message.content[0]['text']
import json
try:
clean_content = content.replace("```json", "").replace("```", "").strip()
result = json.loads(clean_content)
result = extract_json_from_response(content)
result["model_used"] = QWEN_MODEL
return result
except:
return {"raw_response": content}
except Exception as e:
print(f"JSON Parse Error in recognize_card: {e}")
return {"raw_response": content, "error": str(e), "model_used": QWEN_MODEL}
else:
return {"error": f"API Error: {response.code} - {response.message}"}
@@ -602,13 +632,13 @@ def recognize_spread_with_qwen(image_path: str) -> dict:
if response.status_code == 200:
content = response.output.choices[0].message.content[0]['text']
import json
try:
clean_content = content.replace("```json", "").replace("```", "").strip()
result = json.loads(clean_content)
result = extract_json_from_response(content)
result["model_used"] = QWEN_MODEL
return result
except:
return {"raw_response": content, "spread_name": "Unknown"}
except Exception as e:
print(f"JSON Parse Error in recognize_spread: {e}")
return {"raw_response": content, "error": str(e), "spread_name": "Unknown", "model_used": QWEN_MODEL}
else:
return {"error": f"API Error: {response.code} - {response.message}"}
@@ -951,6 +981,10 @@ async def recognize_tarot(
processor = request.app.state.processor
try:
# 在执行 GPU 操作前,切换到线程中运行,避免阻塞主线程(虽然 SAM3 推理在 CPU 上可能已经很快,但为了保险)
# 注意processor 内部调用了 torch如果是在 GPU 上,最好不要多线程调用同一个 model
# 但这里只是推理,且是单次请求。
# 如果是 CPU 推理run_in_executor 有助于防止阻塞 loop
inference_state = processor.set_image(image)
output = processor.set_text_prompt(state=inference_state, prompt="tarot card")
masks, boxes, scores = output["masks"], output["boxes"], output["scores"]
@@ -975,15 +1009,25 @@ async def recognize_tarot(
main_file_path = None
main_file_url = None
# Step 0: 牌阵识别
# Step 0: 牌阵识别 (异步启动)
spread_info = {"spread_name": "Unknown"}
spread_task = None
if main_file_path:
# 使用原始图的一份拷贝给 Qwen 识别牌阵
temp_raw_path = os.path.join(output_dir, "raw_for_spread.jpg")
image.save(temp_raw_path)
spread_info = recognize_spread_with_qwen(temp_raw_path)
# 将同步调用包装为异步任务
loop = asyncio.get_event_loop()
spread_task = loop.run_in_executor(None, recognize_spread_with_qwen, temp_raw_path)
if detected_count != expected_count:
# 如果数量不对,等待牌阵识别完成(如果已启动)再返回
if spread_task:
try:
spread_info = await spread_task
except Exception as e:
print(f"Spread recognition failed: {e}")
duration = time.time() - start_time
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}", duration=duration)
return JSONResponse(
@@ -1005,21 +1049,47 @@ async def recognize_tarot(
append_to_history("tarot-recognize", f"expected: {expected_count}", "failed", details=f"Crop Error: {str(e)}", duration=duration)
raise HTTPException(status_code=500, detail=f"抠图处理错误: {str(e)}")
# 遍历每张卡片进行识别
# 遍历每张卡片进行识别 (并发)
tarot_cards = []
# 1. 准备任务列表
loop = asyncio.get_event_loop()
card_tasks = []
for obj in saved_objects:
fname = obj["filename"]
file_path = os.path.join(output_dir, fname)
# Qwen-VL 识别 (串行)
recognition_res = recognize_card_with_qwen(file_path)
# 创建异步任务
# 使lambda 来延迟调用,确保参数传递正确
task = loop.run_in_executor(None, recognize_card_with_qwen, file_path)
card_tasks.append(task)
# 2. 等待所有卡片识别任务完成
# 同时等待牌阵识别任务 (如果还在运行)
if card_tasks:
all_card_results = await asyncio.gather(*card_tasks)
else:
all_card_results = []
if spread_task:
try:
# 如果之前没有await spread_task这里确保它完成
# 注意:如果 detected_count != expected_count 分支已经 await 过了,这里不会重复执行
# 但那个分支有 return所以这里肯定是还没 await 的
spread_info = await spread_task
except Exception as e:
print(f"Spread recognition failed: {e}")
# 3. 组装结果
for i, obj in enumerate(saved_objects):
fname = obj["filename"]
file_url = str(request.url_for("static", path=f"results/{request_id}/{fname}"))
tarot_cards.append({
"url": file_url,
"is_rotated": obj["is_rotated_by_algorithm"],
"orientation_status": "corrected_to_portrait" if obj["is_rotated_by_algorithm"] else "original_portrait",
"recognition": recognition_res,
"recognition": all_card_results[i],
"note": obj["note"]
})
@@ -1083,14 +1153,26 @@ async def segment_face(
# 调用独立服务进行处理
try:
result = human_analysis_service.process_face_segmentation_and_analysis(
processor=processor,
image=image,
prompt=final_prompt,
output_base_dir=RESULT_IMAGE_DIR,
qwen_model=QWEN_MODEL,
analysis_prompt=PROMPTS["face_analysis"]
)
# 使用新增加的异步并发函数
if hasattr(human_analysis_service, "process_face_segmentation_and_analysis_async"):
result = await human_analysis_service.process_face_segmentation_and_analysis_async(
processor=processor,
image=image,
prompt=final_prompt,
output_base_dir=RESULT_IMAGE_DIR,
qwen_model=QWEN_MODEL,
analysis_prompt=PROMPTS["face_analysis"]
)
else:
# 回退到同步
result = human_analysis_service.process_face_segmentation_and_analysis(
processor=processor,
image=image,
prompt=final_prompt,
output_base_dir=RESULT_IMAGE_DIR,
qwen_model=QWEN_MODEL,
analysis_prompt=PROMPTS["face_analysis"]
)
except Exception as e:
import traceback
traceback.print_exc()

View File

@@ -0,0 +1,3 @@
{"timestamp": 1771396524.4495308, "type": "tarot-recognize", "prompt": "expected: 3", "final_prompt": null, "status": "success", "result_path": "results/1771396501_cd6d8769/seg_f8d9ba7c28fc403cbce75516ba2cd3c4.jpg", "details": "Spread: 三张牌", "duration": 23.850417613983154}
{"timestamp": 1771396579.124643, "type": "tarot-recognize", "prompt": "expected: 3", "final_prompt": null, "status": "success", "result_path": "results/1771396461_4ac00fc4/seg_f85c26fe73cf47c79d308c11f3ef3f0c.jpg", "details": "Spread: 三张牌牌阵", "duration": 119.34761571884155}
{"timestamp": 1771396602.0928633, "type": "tarot-recognize", "prompt": "expected: 3", "final_prompt": null, "status": "success", "result_path": "results/1771396577_a0d559ee/seg_976a193b5025413fbc7d6064d9de0680.jpg", "details": "Spread: 三张牌", "duration": 25.696484327316284}

View File

@@ -6,6 +6,8 @@ import numpy as np
import json
import torch
import cv2
import ast
import re
from PIL import Image
from dashscope import MultiModalConversation
@@ -95,6 +97,35 @@ def create_highlighted_visualization(image: Image.Image, masks, output_path: str
# Save
Image.fromarray(result_np).save(output_path)
def extract_json_from_response(text: str) -> dict:
"""
Robustly extract JSON from text, handling:
1. Markdown code blocks (```json ... ```)
2. Single quotes (Python dict style) via ast.literal_eval
"""
try:
# 1. Try to find JSON block
json_match = re.search(r'```json\s*(.*?)\s*```', text, re.DOTALL)
if json_match:
clean_text = json_match.group(1).strip()
else:
# Try to find { ... } block if no markdown
match = re.search(r'\{.*\}', text, re.DOTALL)
if match:
clean_text = match.group(0).strip()
else:
clean_text = text.strip()
# 2. Try standard JSON
return json.loads(clean_text)
except Exception as e1:
# 3. Try ast.literal_eval for single quotes
try:
return ast.literal_eval(clean_text)
except Exception as e2:
# 4. Fail
raise ValueError(f"Could not parse JSON: {e1} | {e2} | Content: {text[:100]}...")
def analyze_demographics_with_qwen(image_path: str, model_name: str = 'qwen-vl-max', prompt_template: str = None) -> dict:
"""
调用 Qwen-VL 模型分析人物的年龄和性别
@@ -131,19 +162,21 @@ def analyze_demographics_with_qwen(image_path: str, model_name: str = 'qwen-vl-m
if response.status_code == 200:
content = response.output.choices[0].message.content[0]['text']
# 清理 Markdown 代码块标记
clean_content = content.replace("```json", "").replace("```", "").strip()
try:
result = json.loads(clean_content)
result = extract_json_from_response(content)
result["model_used"] = model_name
return result
except json.JSONDecodeError:
return {"raw_analysis": clean_content}
except Exception as e:
print(f"JSON Parse Error in face analysis: {e}")
return {"raw_analysis": content, "error": str(e), "model_used": model_name}
else:
return {"error": f"API Error: {response.code} - {response.message}"}
except Exception as e:
return {"error": f"分析失败: {str(e)}"}
import asyncio
def process_face_segmentation_and_analysis(
processor,
image: Image.Image,
@@ -156,11 +189,11 @@ def process_face_segmentation_and_analysis(
核心处理逻辑:
1. SAM3 分割 (默认提示词 "head" 以包含头发)
2. 裁剪图片
3. Qwen-VL 识别性别年龄
3. Qwen-VL 识别性别年龄 (并发)
4. 返回结果
"""
# 1. SAM3 推理
# 1. SAM3 推理 (同步,因为涉及 GPU 操作)
inference_state = processor.set_image(image)
output = processor.set_text_prompt(state=inference_state, prompt=prompt)
masks, boxes, scores = output["masks"], output["boxes"], output["scores"]
@@ -179,7 +212,7 @@ def process_face_segmentation_and_analysis(
output_dir = os.path.join(output_base_dir, request_id)
os.makedirs(output_dir, exist_ok=True)
# --- 新增:生成背景变暗的整体可视化图 ---
# --- 生成可视化图 ---
vis_filename = f"seg_{uuid.uuid4().hex}.jpg"
vis_path = os.path.join(output_dir, vis_filename)
try:
@@ -188,38 +221,238 @@ def process_face_segmentation_and_analysis(
except Exception as e:
print(f"可视化生成失败: {e}")
full_vis_relative_path = None
# -------------------------------------
# ------------------
results = []
# 转换 boxes 为 numpy
# 转换 boxes 和 scores
if isinstance(boxes, torch.Tensor):
boxes_np = boxes.cpu().numpy()
else:
boxes_np = boxes
# 转换 scores 为 list
if isinstance(scores, torch.Tensor):
scores_list = scores.tolist()
else:
scores_list = scores if isinstance(scores, list) else [float(scores)]
for i, box in enumerate(boxes_np):
# 2. 裁剪 (带一点 padding 以保留完整发型)
# 2. 裁剪 (带一点 padding 以保留完整发型)
cropped_img = crop_head_with_padding(image, box, padding_ratio=0.1)
# 准备异步任务
async def run_analysis_tasks():
loop = asyncio.get_event_loop()
tasks = []
temp_results = [] # 存储 (index, filename, score) 以便后续排序组合
for i, box in enumerate(boxes_np):
# 2. 裁剪 (同步)
cropped_img = crop_head_with_padding(image, box, padding_ratio=0.1)
filename = f"face_{i}.jpg"
save_path = os.path.join(output_dir, filename)
cropped_img.save(save_path)
# 3. 准备识别任务
task = loop.run_in_executor(
None,
analyze_demographics_with_qwen,
save_path,
qwen_model,
analysis_prompt
)
tasks.append(task)
temp_results.append({
"filename": filename,
"relative_path": f"results/{request_id}/{filename}",
"score": float(scores_list[i]) if i < len(scores_list) else 0.0
})
# 等待所有任务完成
if tasks:
analysis_results = await asyncio.gather(*tasks)
else:
analysis_results = []
# 组合结果
final_results = []
for i, item in enumerate(temp_results):
item["analysis"] = analysis_results[i]
final_results.append(item)
return final_results
# 运行异步任务
# 注意:由于本函数被 FastAPI (异步环境) 中的同步或异步函数调用,
# 如果上层是 async def我们可以直接 await。
# 但由于这个函数定义没有 async且之前的调用是同步的
# 为了兼容性,我们需要检查当前是否在事件循环中。
# 然而,查看 fastAPI_tarot.py这个函数是在 async def segment_face 中被调用的。
# 但它是作为普通函数被导入和调用的。
# 为了不破坏现有签名,我们可以使用 asyncio.run() 或者在新循环中运行,
# 但这在已经运行的 loop 中是不允许的。
# 最佳方案:修改本函数为 async并在 fastAPI_tarot.py 中 await 它。
# 但这需要修改 fastAPI_tarot.py 的调用处。
# 既然我们已经修改了 fastAPI_tarot.py我们也可以顺便修改这里的签名。
# 但为了稳妥,我们可以用一种 hack
# 如果在一个正在运行的 loop 中调用,我们必须返回 awaitable 或者使用 loop.run_until_complete (会报错)
# 让我们先把这个函数改成 async然后去修改 fastAPI_tarot.py 的调用。
# 这是最正确的做法。
pass # 占位,实际代码在下面
async def process_face_segmentation_and_analysis_async(
processor,
image: Image.Image,
prompt: str = "head",
output_base_dir: str = "static/results",
qwen_model: str = "qwen-vl-max",
analysis_prompt: str = None
) -> dict:
# ... (同上逻辑,只是是 async)
# 1. SAM3 推理
inference_state = processor.set_image(image)
output = processor.set_text_prompt(state=inference_state, prompt=prompt)
masks, boxes, scores = output["masks"], output["boxes"], output["scores"]
detected_count = len(masks)
if detected_count == 0:
return {
"status": "success",
"message": "未检测到目标",
"detected_count": 0,
"results": []
}
# 保存裁剪图
request_id = f"{int(time.time())}_{uuid.uuid4().hex[:8]}"
output_dir = os.path.join(output_base_dir, request_id)
os.makedirs(output_dir, exist_ok=True)
vis_filename = f"seg_{uuid.uuid4().hex}.jpg"
vis_path = os.path.join(output_dir, vis_filename)
try:
create_highlighted_visualization(image, masks, vis_path)
full_vis_relative_path = f"results/{request_id}/{vis_filename}"
except Exception as e:
print(f"可视化生成失败: {e}")
full_vis_relative_path = None
if isinstance(boxes, torch.Tensor):
boxes_np = boxes.cpu().numpy()
else:
boxes_np = boxes
if isinstance(scores, torch.Tensor):
scores_list = scores.tolist()
else:
scores_list = scores if isinstance(scores, list) else [float(scores)]
loop = asyncio.get_event_loop()
tasks = []
results = []
for i, box in enumerate(boxes_np):
cropped_img = crop_head_with_padding(image, box, padding_ratio=0.1)
filename = f"face_{i}.jpg"
save_path = os.path.join(output_dir, filename)
cropped_img.save(save_path)
# 3. 识别
task = loop.run_in_executor(
None,
analyze_demographics_with_qwen,
save_path,
qwen_model,
analysis_prompt
)
tasks.append(task)
results.append({
"filename": filename,
"relative_path": f"results/{request_id}/{filename}",
"score": float(scores_list[i]) if i < len(scores_list) else 0.0
})
if tasks:
analysis_results = await asyncio.gather(*tasks)
else:
analysis_results = []
for i, item in enumerate(results):
item["analysis"] = analysis_results[i]
return {
"status": "success",
"message": f"成功检测并分析 {detected_count} 个人脸",
"detected_count": detected_count,
"request_id": request_id,
"full_visualization": full_vis_relative_path,
"scores": scores_list,
"results": results
}
# 保留旧的同步接口以兼容其他潜在调用者,但内部实现可能会有问题如果它在 loop 中运行
# 既然我们主要关注 fastAPI_tarot.py我们可以直接替换 process_face_segmentation_and_analysis
# 或者让它只是一个 wrapper
def process_face_segmentation_and_analysis(
processor,
image: Image.Image,
prompt: str = "head",
output_base_dir: str = "static/results",
qwen_model: str = "qwen-vl-max",
analysis_prompt: str = None
) -> dict:
"""
同步版本 (保留以兼容)
注意:如果在 async loop 中调用此函数,且此函数内部没有异步操作,则会阻塞 loop。
如果需要异步并发,请使用 process_face_segmentation_and_analysis_async
"""
# 这里我们简单地复用逻辑,但去除异步部分,退化为串行
# 1. SAM3 推理
inference_state = processor.set_image(image)
output = processor.set_text_prompt(state=inference_state, prompt=prompt)
masks, boxes, scores = output["masks"], output["boxes"], output["scores"]
detected_count = len(masks)
if detected_count == 0:
return {
"status": "success",
"message": "未检测到目标",
"detected_count": 0,
"results": []
}
request_id = f"{int(time.time())}_{uuid.uuid4().hex[:8]}"
output_dir = os.path.join(output_base_dir, request_id)
os.makedirs(output_dir, exist_ok=True)
vis_filename = f"seg_{uuid.uuid4().hex}.jpg"
vis_path = os.path.join(output_dir, vis_filename)
try:
create_highlighted_visualization(image, masks, vis_path)
full_vis_relative_path = f"results/{request_id}/{vis_filename}"
except Exception as e:
print(f"可视化生成失败: {e}")
full_vis_relative_path = None
if isinstance(boxes, torch.Tensor):
boxes_np = boxes.cpu().numpy()
else:
boxes_np = boxes
if isinstance(scores, torch.Tensor):
scores_list = scores.tolist()
else:
scores_list = scores if isinstance(scores, list) else [float(scores)]
results = []
for i, box in enumerate(boxes_np):
cropped_img = crop_head_with_padding(image, box, padding_ratio=0.1)
filename = f"face_{i}.jpg"
save_path = os.path.join(output_dir, filename)
cropped_img.save(save_path)
# 同步调用
analysis = analyze_demographics_with_qwen(save_path, model_name=qwen_model, prompt_template=analysis_prompt)
# 构造返回结果
# 注意URL 生成需要依赖外部的 request context这里只返回相对路径或文件名
# 由调用方组装完整 URL
results.append({
"filename": filename,
"relative_path": f"results/{request_id}/{filename}",
@@ -232,7 +465,8 @@ def process_face_segmentation_and_analysis(
"message": f"成功检测并分析 {detected_count} 个人脸",
"detected_count": detected_count,
"request_id": request_id,
"full_visualization": full_vis_relative_path, # 返回相对路径
"scores": scores_list, # 返回全部分数
"full_visualization": full_vis_relative_path,
"scores": scores_list,
"results": results
}
}

View File

@@ -144,7 +144,7 @@
</div>
<div>
<h2 class="font-bold text-lg leading-tight tracking-tight">SAM3 Admin</h2>
<p class="text-[10px] text-slate-400 font-bold tracking-widest uppercase mt-0.5">Quant Speed AI</p>
<p class="text-[10px] text-slate-400 font-bold tracking-widest uppercase mt-0.5">Quantum Track AI</p>
</div>
</div>
@@ -155,6 +155,11 @@
<i class="fas fa-chart-pie w-5 text-center transition-colors group-hover:text-blue-600" :class="currentTab === 'dashboard' ? 'text-blue-600' : 'text-slate-400'"></i>
<span class="font-medium">数据看板</span>
</a>
<a href="#" @click.prevent="switchTab('tarot')" :class="{ 'active': currentTab === 'tarot' }"
class="nav-link flex items-center gap-3 px-4 py-3 text-slate-600 hover:bg-slate-50 hover:text-blue-600 rounded-xl transition-all duration-200 group">
<i class="fas fa-star w-5 text-center transition-colors group-hover:text-blue-600" :class="currentTab === 'tarot' ? 'text-blue-600' : 'text-slate-400'"></i>
<span class="font-medium">塔罗牌识别</span>
</a>
<a href="#" @click.prevent="switchTab('gpu')" :class="{ 'active': currentTab === 'gpu' }"
class="nav-link flex items-center gap-3 px-4 py-3 text-slate-600 hover:bg-slate-50 hover:text-blue-600 rounded-xl transition-all duration-200 group">
<i class="fas fa-microchip w-5 text-center transition-colors group-hover:text-blue-600" :class="currentTab === 'gpu' ? 'text-blue-600' : 'text-slate-400'"></i>
@@ -372,6 +377,158 @@
</div>
<!-- Tarot Tab -->
<div v-if="currentTab === 'tarot'" key="tarot" class="space-y-6">
<!-- Input Section -->
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 p-6">
<h3 class="font-bold text-slate-800 mb-6 flex items-center gap-2">
<span class="w-1 h-5 bg-purple-500 rounded-full"></span>
塔罗牌识别任务
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Image Upload -->
<div class="space-y-4">
<label class="block text-sm font-medium text-slate-700">上传图片</label>
<div class="flex items-center justify-center w-full">
<label for="dropzone-file" class="flex flex-col items-center justify-center w-full h-64 border-2 border-slate-300 border-dashed rounded-xl cursor-pointer bg-slate-50 hover:bg-slate-100 transition-colors relative overflow-hidden">
<div v-if="!tarotFile && !tarotImageUrl" class="flex flex-col items-center justify-center pt-5 pb-6">
<i class="fas fa-cloud-upload-alt text-4xl text-slate-400 mb-3"></i>
<p class="mb-2 text-sm text-slate-500"><span class="font-semibold">点击上传</span> 或拖拽文件到此处</p>
<p class="text-xs text-slate-400">支持 JPG, PNG (MAX. 10MB)</p>
</div>
<div v-else class="absolute inset-0 flex items-center justify-center bg-slate-100">
<img v-if="tarotPreview" :src="tarotPreview" class="max-h-full max-w-full object-contain">
<div v-else class="text-slate-500 flex flex-col items-center">
<i class="fas fa-link text-2xl mb-2"></i>
<span class="text-xs truncate max-w-[200px]">{{ tarotImageUrl }}</span>
</div>
<button @click.prevent="clearTarotInput" class="absolute top-2 right-2 bg-white/80 p-1.5 rounded-full hover:bg-white text-slate-600 shadow-sm">
<i class="fas fa-times"></i>
</button>
</div>
<input id="dropzone-file" type="file" class="hidden" accept="image/*" @change="handleTarotFileChange" />
</label>
</div>
<div class="relative">
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<i class="fas fa-link text-slate-400"></i>
</div>
<input type="text" v-model="tarotImageUrl" @input="handleUrlInput"
class="bg-slate-50 border border-slate-200 text-slate-900 text-sm rounded-xl focus:ring-purple-500 focus:border-purple-500 block w-full pl-10 p-2.5 outline-none"
placeholder="或者输入图片 URL...">
</div>
</div>
<!-- Settings -->
<div class="space-y-6">
<div>
<label class="block text-sm font-medium text-slate-700 mb-2">预期卡牌数量</label>
<div class="flex items-center gap-4">
<input type="number" v-model.number="tarotExpectedCount" min="1" max="10"
class="bg-slate-50 border border-slate-200 text-slate-900 text-sm rounded-xl focus:ring-purple-500 focus:border-purple-500 block w-full p-2.5 outline-none font-mono">
<span class="text-sm text-slate-500 whitespace-nowrap"></span>
</div>
<p class="text-xs text-slate-400 mt-2">系统将尝试检测并分割指定数量的卡牌。如果检测数量不符,将返回错误提示。</p>
</div>
<div class="pt-4">
<button @click="recognizeTarot" :disabled="isRecognizing || (!tarotFile && !tarotImageUrl)"
class="w-full bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-700 hover:to-indigo-700 text-white font-bold py-3 px-4 rounded-xl shadow-lg shadow-purple-500/30 transition-all transform active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2">
<i class="fas fa-magic" :class="{'fa-spin': isRecognizing}"></i>
{{ isRecognizing ? '正在识别中...' : '开始识别 (Recognize)' }}
</button>
</div>
</div>
</div>
</div>
<!-- Results Section -->
<div v-if="tarotResult" class="space-y-6 animate-fade-in">
<!-- Status Banner -->
<div :class="tarotResult.status === 'success' ? 'bg-green-50 border-green-200 text-green-700' : 'bg-red-50 border-red-200 text-red-700'"
class="p-4 rounded-xl border flex items-center gap-3 shadow-sm">
<i :class="tarotResult.status === 'success' ? 'fas fa-check-circle' : 'fas fa-exclamation-circle'" class="text-xl"></i>
<div>
<h4 class="font-bold">{{ tarotResult.status === 'success' ? '识别成功' : '识别失败' }}</h4>
<p class="text-sm opacity-90">{{ tarotResult.message }}</p>
</div>
</div>
<!-- Spread Info -->
<div v-if="tarotResult.spread_info" class="bg-white rounded-2xl shadow-sm border border-slate-100 p-6">
<h3 class="font-bold text-slate-800 mb-4 flex items-center gap-2">
<i class="fas fa-layer-group text-purple-500"></i>
牌阵信息
</h3>
<div class="bg-purple-50 rounded-xl p-4 border border-purple-100">
<div class="flex flex-col md:flex-row gap-4">
<div class="md:w-1/3">
<span class="text-xs font-bold text-purple-400 uppercase tracking-wider">牌阵名称</span>
<div class="text-xl font-bold text-slate-800 mt-1">{{ tarotResult.spread_info.spread_name }}</div>
</div>
<div class="md:w-2/3 border-t md:border-t-0 md:border-l border-purple-200 pt-4 md:pt-0 md:pl-4">
<span class="text-xs font-bold text-purple-400 uppercase tracking-wider">描述 / 寓意</span>
<div class="text-sm text-slate-700 mt-1 leading-relaxed">{{ tarotResult.spread_info.description || '暂无描述' }}</div>
</div>
</div>
<div v-if="tarotResult.spread_info.model_used" class="mt-3 pt-3 border-t border-purple-200 flex items-center gap-2">
<span class="text-xs bg-white text-purple-600 px-2 py-0.5 rounded border border-purple-200 font-mono">
<i class="fas fa-robot mr-1"></i>{{ tarotResult.spread_info.model_used }}
</span>
</div>
</div>
</div>
<!-- Cards Grid -->
<div v-if="tarotResult.tarot_cards && tarotResult.tarot_cards.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div v-for="(card, index) in tarotResult.tarot_cards" :key="index" class="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden hover:shadow-md transition-shadow group">
<div class="relative aspect-[2/3] bg-slate-100 overflow-hidden cursor-pointer" @click="previewImage(card.url)">
<img :src="card.url" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500">
<div class="absolute top-2 right-2 bg-black/60 backdrop-blur-sm text-white text-xs font-bold px-2 py-1 rounded">
#{{ index + 1 }}
</div>
</div>
<div class="p-4 space-y-3">
<div class="flex justify-between items-start">
<div>
<h4 class="font-bold text-lg text-slate-800">{{ card.recognition?.name || '未知' }}</h4>
<div class="flex items-center gap-2 mt-1">
<span :class="card.recognition?.position === '正位' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
class="text-xs font-bold px-2 py-0.5 rounded">
{{ card.recognition?.position || '未知' }}
</span>
<span v-if="card.is_rotated" class="text-[10px] text-slate-400 bg-slate-100 px-1.5 rounded" title="已自动矫正方向">
<i class="fas fa-sync-alt"></i> Auto-Rotated
</span>
</div>
</div>
</div>
<div v-if="card.recognition?.model_used" class="pt-3 border-t border-slate-100 flex items-center justify-between text-xs">
<span class="text-slate-400">Model Used:</span>
<span class="font-mono text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded border border-purple-100">
{{ card.recognition.model_used }}
</span>
</div>
</div>
</div>
</div>
<!-- Full Visualization -->
<div v-if="tarotResult.full_visualization" class="bg-white rounded-2xl shadow-sm border border-slate-100 p-6">
<h3 class="font-bold text-slate-800 mb-4 flex items-center gap-2">
<i class="fas fa-image text-blue-500"></i>
整体可视化结果
</h3>
<div class="rounded-xl overflow-hidden border border-slate-200 cursor-pointer" @click="previewImage(tarotResult.full_visualization)">
<img :src="tarotResult.full_visualization" class="w-full h-auto">
</div>
</div>
</div>
</div>
<!-- History Tab -->
<div v-if="currentTab === 'history'" key="history" class="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden">
<div class="overflow-x-auto">
@@ -383,7 +540,7 @@
<th class="px-6 py-4">Prompt / 详情</th>
<th class="px-6 py-4 text-center">耗时</th>
<th class="px-6 py-4 text-center">状态</th>
<th class="px-6 py-4 text-center">查看</th>
<th class="px-6 py-4 text-center">操作</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
@@ -787,6 +944,14 @@
const gpuStatus = ref({});
const gpuHistory = ref([]);
let gpuInterval = null;
// Tarot State
const tarotFile = ref(null);
const tarotImageUrl = ref('');
const tarotExpectedCount = ref(3);
const tarotPreview = ref(null);
const tarotResult = ref(null);
const isRecognizing = ref(false);
// Filters
const selectedTimeRange = ref('all');
@@ -1147,6 +1312,90 @@
} catch (e) { alert('删除失败: ' + e.message); }
};
// --- Tarot Actions ---
const handleTarotFileChange = (event) => {
const file = event.target.files[0];
if (file) {
tarotFile.value = file;
tarotImageUrl.value = ''; // Clear URL if file is selected
tarotPreview.value = URL.createObjectURL(file);
tarotResult.value = null; // Clear previous result
}
};
const handleUrlInput = () => {
if (tarotImageUrl.value) {
tarotFile.value = null; // Clear file if URL is entered
tarotPreview.value = null; // Can't preview external URL easily without loading it, or just use the URL
// Simple preview for URL
tarotPreview.value = tarotImageUrl.value;
tarotResult.value = null;
} else {
tarotPreview.value = null;
}
};
const clearTarotInput = () => {
tarotFile.value = null;
tarotImageUrl.value = '';
tarotPreview.value = null;
tarotResult.value = null;
// Reset file input value
const fileInput = document.getElementById('dropzone-file');
if (fileInput) fileInput.value = '';
};
const recognizeTarot = async () => {
if (!tarotFile.value && !tarotImageUrl.value) return;
isRecognizing.value = true;
tarotResult.value = null;
try {
const formData = new FormData();
if (tarotFile.value) {
formData.append('file', tarotFile.value);
} else {
formData.append('image_url', tarotImageUrl.value);
}
formData.append('expected_count', tarotExpectedCount.value);
// Use axios directly or a helper. Need to handle API Key if required by backend,
// but admin usually has session. Wait, the backend endpoints like /recognize_tarot
// require X-API-Key header.
// The admin page uses cookie for /admin/api/* but /recognize_tarot is a public API protected by Key.
// We should add the key to the header.
const config = {
headers: {
'X-API-Key': '123quant-speed' // Hardcoded as per fastAPI_tarot.py VALID_API_KEY
},
timeout: 120000 // 2分钟超时大模型响应较慢
};
const res = await axios.post('/recognize_tarot', formData, config);
tarotResult.value = res.data;
} catch (e) {
console.error(e);
let msg = e.response?.data?.detail || e.message || '识别请求失败';
// 针对 504 Gateway Timeout 或 请求超时做特殊提示
if (e.response && e.response.status === 504) {
msg = '请求超时 (504):大模型处理时间较长。后台可能仍在运行,请稍后在“识别记录”中刷新查看结果。';
} else if (e.code === 'ECONNABORTED') {
msg = '请求超时:网络连接中断或服务器响应过慢。请稍后重试。';
}
tarotResult.value = {
status: 'failed',
message: msg
};
} finally {
isRecognizing.value = false;
}
};
// --- Navigation & Helpers ---
const switchTab = (tab) => {
const prevTab = currentTab.value;
@@ -1236,6 +1485,7 @@
const getPageTitle = (tab) => {
const map = {
'dashboard': '数据看板',
'tarot': '塔罗牌识别',
'history': '识别记录',
'files': '文件资源管理',
'prompts': '提示词工程',
@@ -1248,6 +1498,7 @@
const getPageSubtitle = (tab) => {
const map = {
'dashboard': '系统运行状态与核心指标概览',
'tarot': 'SAM3 + Qwen-VL 联合识别与分割',
'history': '所有视觉识别任务的历史流水',
'files': '查看和管理生成的图像及JSON结果',
'prompts': '调整各个识别场景的 System Prompt',
@@ -1460,7 +1711,10 @@
selectedTimeRange, selectedType,
barChartRef, pieChartRef, promptPieChartRef, promptBarChartRef, wordCloudRef,
formatBytes, gpuStatus,
gpuUtilChartRef, gpuTempChartRef
gpuUtilChartRef, gpuTempChartRef,
// Tarot
tarotFile, tarotImageUrl, tarotExpectedCount, tarotPreview, tarotResult, isRecognizing,
handleTarotFileChange, handleUrlInput, clearTarotInput, recognizeTarot
};
}
}).mount('#app');

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB