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 asyncio
import shutil import shutil
import subprocess import subprocess
import ast
from datetime import datetime from datetime import datetime
from typing import Optional, List, Dict, Any from typing import Optional, List, Dict, Any
from contextlib import asynccontextmanager 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}") 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: def translate_to_sam3_prompt(text: str) -> str:
""" """
使用 Qwen 模型将中文提示词翻译为英文 使用 Qwen 模型将中文提示词翻译为英文
@@ -567,13 +597,13 @@ def recognize_card_with_qwen(image_path: str) -> dict:
if response.status_code == 200: if response.status_code == 200:
content = response.output.choices[0].message.content[0]['text'] content = response.output.choices[0].message.content[0]['text']
import json
try: try:
clean_content = content.replace("```json", "").replace("```", "").strip() result = extract_json_from_response(content)
result = json.loads(clean_content) result["model_used"] = QWEN_MODEL
return result return result
except: except Exception as e:
return {"raw_response": content} print(f"JSON Parse Error in recognize_card: {e}")
return {"raw_response": content, "error": str(e), "model_used": QWEN_MODEL}
else: else:
return {"error": f"API Error: {response.code} - {response.message}"} 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: if response.status_code == 200:
content = response.output.choices[0].message.content[0]['text'] content = response.output.choices[0].message.content[0]['text']
import json
try: try:
clean_content = content.replace("```json", "").replace("```", "").strip() result = extract_json_from_response(content)
result = json.loads(clean_content) result["model_used"] = QWEN_MODEL
return result return result
except: except Exception as e:
return {"raw_response": content, "spread_name": "Unknown"} print(f"JSON Parse Error in recognize_spread: {e}")
return {"raw_response": content, "error": str(e), "spread_name": "Unknown", "model_used": QWEN_MODEL}
else: else:
return {"error": f"API Error: {response.code} - {response.message}"} return {"error": f"API Error: {response.code} - {response.message}"}
@@ -951,6 +981,10 @@ async def recognize_tarot(
processor = request.app.state.processor processor = request.app.state.processor
try: try:
# 在执行 GPU 操作前,切换到线程中运行,避免阻塞主线程(虽然 SAM3 推理在 CPU 上可能已经很快,但为了保险)
# 注意processor 内部调用了 torch如果是在 GPU 上,最好不要多线程调用同一个 model
# 但这里只是推理,且是单次请求。
# 如果是 CPU 推理run_in_executor 有助于防止阻塞 loop
inference_state = processor.set_image(image) inference_state = processor.set_image(image)
output = processor.set_text_prompt(state=inference_state, prompt="tarot card") output = processor.set_text_prompt(state=inference_state, prompt="tarot card")
masks, boxes, scores = output["masks"], output["boxes"], output["scores"] masks, boxes, scores = output["masks"], output["boxes"], output["scores"]
@@ -975,15 +1009,25 @@ async def recognize_tarot(
main_file_path = None main_file_path = None
main_file_url = None main_file_url = None
# Step 0: 牌阵识别 # Step 0: 牌阵识别 (异步启动)
spread_info = {"spread_name": "Unknown"} spread_info = {"spread_name": "Unknown"}
spread_task = None
if main_file_path: if main_file_path:
# 使用原始图的一份拷贝给 Qwen 识别牌阵 # 使用原始图的一份拷贝给 Qwen 识别牌阵
temp_raw_path = os.path.join(output_dir, "raw_for_spread.jpg") temp_raw_path = os.path.join(output_dir, "raw_for_spread.jpg")
image.save(temp_raw_path) 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 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 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) 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( 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) 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)}") raise HTTPException(status_code=500, detail=f"抠图处理错误: {str(e)}")
# 遍历每张卡片进行识别 # 遍历每张卡片进行识别 (并发)
tarot_cards = [] tarot_cards = []
# 1. 准备任务列表
loop = asyncio.get_event_loop()
card_tasks = []
for obj in saved_objects: for obj in saved_objects:
fname = obj["filename"] fname = obj["filename"]
file_path = os.path.join(output_dir, fname) file_path = os.path.join(output_dir, fname)
# 创建异步任务
# 使用 lambda 来延迟调用,确保参数传递正确
task = loop.run_in_executor(None, recognize_card_with_qwen, file_path)
card_tasks.append(task)
# 调用 Qwen-VL 识别 (串行) # 2. 等待所有卡片识别任务完成
recognition_res = recognize_card_with_qwen(file_path) # 同时等待牌阵识别任务 (如果还在运行)
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}")) file_url = str(request.url_for("static", path=f"results/{request_id}/{fname}"))
tarot_cards.append({ tarot_cards.append({
"url": file_url, "url": file_url,
"is_rotated": obj["is_rotated_by_algorithm"], "is_rotated": obj["is_rotated_by_algorithm"],
"orientation_status": "corrected_to_portrait" if obj["is_rotated_by_algorithm"] else "original_portrait", "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"] "note": obj["note"]
}) })
@@ -1083,6 +1153,18 @@ async def segment_face(
# 调用独立服务进行处理 # 调用独立服务进行处理
try: try:
# 使用新增加的异步并发函数
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( result = human_analysis_service.process_face_segmentation_and_analysis(
processor=processor, processor=processor,
image=image, image=image,

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 json
import torch import torch
import cv2 import cv2
import ast
import re
from PIL import Image from PIL import Image
from dashscope import MultiModalConversation from dashscope import MultiModalConversation
@@ -95,6 +97,35 @@ def create_highlighted_visualization(image: Image.Image, masks, output_path: str
# Save # Save
Image.fromarray(result_np).save(output_path) 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: def analyze_demographics_with_qwen(image_path: str, model_name: str = 'qwen-vl-max', prompt_template: str = None) -> dict:
""" """
调用 Qwen-VL 模型分析人物的年龄和性别 调用 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: if response.status_code == 200:
content = response.output.choices[0].message.content[0]['text'] content = response.output.choices[0].message.content[0]['text']
# 清理 Markdown 代码块标记
clean_content = content.replace("```json", "").replace("```", "").strip()
try: try:
result = json.loads(clean_content) result = extract_json_from_response(content)
result["model_used"] = model_name
return result return result
except json.JSONDecodeError: except Exception as e:
return {"raw_analysis": clean_content} print(f"JSON Parse Error in face analysis: {e}")
return {"raw_analysis": content, "error": str(e), "model_used": model_name}
else: else:
return {"error": f"API Error: {response.code} - {response.message}"} return {"error": f"API Error: {response.code} - {response.message}"}
except Exception as e: except Exception as e:
return {"error": f"分析失败: {str(e)}"} return {"error": f"分析失败: {str(e)}"}
import asyncio
def process_face_segmentation_and_analysis( def process_face_segmentation_and_analysis(
processor, processor,
image: Image.Image, image: Image.Image,
@@ -156,11 +189,11 @@ def process_face_segmentation_and_analysis(
核心处理逻辑: 核心处理逻辑:
1. SAM3 分割 (默认提示词 "head" 以包含头发) 1. SAM3 分割 (默认提示词 "head" 以包含头发)
2. 裁剪图片 2. 裁剪图片
3. Qwen-VL 识别性别年龄 3. Qwen-VL 识别性别年龄 (并发)
4. 返回结果 4. 返回结果
""" """
# 1. SAM3 推理 # 1. SAM3 推理 (同步,因为涉及 GPU 操作)
inference_state = processor.set_image(image) inference_state = processor.set_image(image)
output = processor.set_text_prompt(state=inference_state, prompt=prompt) output = processor.set_text_prompt(state=inference_state, prompt=prompt)
masks, boxes, scores = output["masks"], output["boxes"], output["scores"] 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) output_dir = os.path.join(output_base_dir, request_id)
os.makedirs(output_dir, exist_ok=True) os.makedirs(output_dir, exist_ok=True)
# --- 新增:生成背景变暗的整体可视化图 --- # --- 生成可视化图 ---
vis_filename = f"seg_{uuid.uuid4().hex}.jpg" vis_filename = f"seg_{uuid.uuid4().hex}.jpg"
vis_path = os.path.join(output_dir, vis_filename) vis_path = os.path.join(output_dir, vis_filename)
try: try:
@@ -188,38 +221,238 @@ def process_face_segmentation_and_analysis(
except Exception as e: except Exception as e:
print(f"可视化生成失败: {e}") print(f"可视化生成失败: {e}")
full_vis_relative_path = None full_vis_relative_path = None
# ------------------------------------- # ------------------
results = [] # 转换 boxes 和 scores
# 转换 boxes 为 numpy
if isinstance(boxes, torch.Tensor): if isinstance(boxes, torch.Tensor):
boxes_np = boxes.cpu().numpy() boxes_np = boxes.cpu().numpy()
else: else:
boxes_np = boxes boxes_np = boxes
# 转换 scores 为 list
if isinstance(scores, torch.Tensor): if isinstance(scores, torch.Tensor):
scores_list = scores.tolist() scores_list = scores.tolist()
else: else:
scores_list = scores if isinstance(scores, list) else [float(scores)] scores_list = scores if isinstance(scores, list) else [float(scores)]
for i, box in enumerate(boxes_np): # 准备异步任务
# 2. 裁剪 (带一点 padding 以保留完整发型) async def run_analysis_tasks():
# 2. 裁剪 (带一点 padding 以保留完整发型) loop = asyncio.get_event_loop()
cropped_img = crop_head_with_padding(image, box, padding_ratio=0.1) 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" filename = f"face_{i}.jpg"
save_path = os.path.join(output_dir, filename) save_path = os.path.join(output_dir, filename)
cropped_img.save(save_path) cropped_img.save(save_path)
# 3. 识别 # 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)
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) analysis = analyze_demographics_with_qwen(save_path, model_name=qwen_model, prompt_template=analysis_prompt)
# 构造返回结果
# 注意URL 生成需要依赖外部的 request context这里只返回相对路径或文件名
# 由调用方组装完整 URL
results.append({ results.append({
"filename": filename, "filename": filename,
"relative_path": f"results/{request_id}/{filename}", "relative_path": f"results/{request_id}/{filename}",
@@ -232,7 +465,8 @@ def process_face_segmentation_and_analysis(
"message": f"成功检测并分析 {detected_count} 个人脸", "message": f"成功检测并分析 {detected_count} 个人脸",
"detected_count": detected_count, "detected_count": detected_count,
"request_id": request_id, "request_id": request_id,
"full_visualization": full_vis_relative_path, # 返回相对路径 "full_visualization": full_vis_relative_path,
"scores": scores_list, # 返回全部分数 "scores": scores_list,
"results": results "results": results
} }

View File

@@ -144,7 +144,7 @@
</div> </div>
<div> <div>
<h2 class="font-bold text-lg leading-tight tracking-tight">SAM3 Admin</h2> <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>
</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> <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> <span class="font-medium">数据看板</span>
</a> </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' }" <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"> 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> <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> </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 --> <!-- History Tab -->
<div v-if="currentTab === 'history'" key="history" class="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden"> <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"> <div class="overflow-x-auto">
@@ -383,7 +540,7 @@
<th class="px-6 py-4">Prompt / 详情</th> <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>
<th class="px-6 py-4 text-center">查看</th> <th class="px-6 py-4 text-center">操作</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-slate-100"> <tbody class="divide-y divide-slate-100">
@@ -788,6 +945,14 @@
const gpuHistory = ref([]); const gpuHistory = ref([]);
let gpuInterval = null; 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 // Filters
const selectedTimeRange = ref('all'); const selectedTimeRange = ref('all');
const selectedType = ref('all'); const selectedType = ref('all');
@@ -1147,6 +1312,90 @@
} catch (e) { alert('删除失败: ' + e.message); } } 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 --- // --- Navigation & Helpers ---
const switchTab = (tab) => { const switchTab = (tab) => {
const prevTab = currentTab.value; const prevTab = currentTab.value;
@@ -1236,6 +1485,7 @@
const getPageTitle = (tab) => { const getPageTitle = (tab) => {
const map = { const map = {
'dashboard': '数据看板', 'dashboard': '数据看板',
'tarot': '塔罗牌识别',
'history': '识别记录', 'history': '识别记录',
'files': '文件资源管理', 'files': '文件资源管理',
'prompts': '提示词工程', 'prompts': '提示词工程',
@@ -1248,6 +1498,7 @@
const getPageSubtitle = (tab) => { const getPageSubtitle = (tab) => {
const map = { const map = {
'dashboard': '系统运行状态与核心指标概览', 'dashboard': '系统运行状态与核心指标概览',
'tarot': 'SAM3 + Qwen-VL 联合识别与分割',
'history': '所有视觉识别任务的历史流水', 'history': '所有视觉识别任务的历史流水',
'files': '查看和管理生成的图像及JSON结果', 'files': '查看和管理生成的图像及JSON结果',
'prompts': '调整各个识别场景的 System Prompt', 'prompts': '调整各个识别场景的 System Prompt',
@@ -1460,7 +1711,10 @@
selectedTimeRange, selectedType, selectedTimeRange, selectedType,
barChartRef, pieChartRef, promptPieChartRef, promptBarChartRef, wordCloudRef, barChartRef, pieChartRef, promptPieChartRef, promptBarChartRef, wordCloudRef,
formatBytes, gpuStatus, formatBytes, gpuStatus,
gpuUtilChartRef, gpuTempChartRef gpuUtilChartRef, gpuTempChartRef,
// Tarot
tarotFile, tarotImageUrl, tarotExpectedCount, tarotPreview, tarotResult, isRecognizing,
handleTarotFileChange, handleUrlInput, clearTarotInput, recognizeTarot
}; };
} }
}).mount('#app'); }).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