Compare commits
9 Commits
4667021944
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 53e8fbb4dd | |||
| f7c73fa57e | |||
| bad6bfa34b | |||
| 054e720e39 | |||
| f8e94328a7 | |||
| aee6f8804f | |||
| 765a0aebdc | |||
| dc5a02f4ec | |||
| 4f6d7d9035 |
@@ -1,5 +1,11 @@
|
|||||||
# 量迹AI · SAM3「分割一切」视觉分割服务
|
# 量迹AI · SAM3「分割一切」视觉分割服务
|
||||||
|
|
||||||
|
|
||||||
|
# Admin Config
|
||||||
|
ADMIN_PASSWORD = "admin_secure_password" # 可以根据需求修改
|
||||||
|
HISTORY_FILE = "history.json"
|
||||||
|
|
||||||
|
|
||||||
本项目在开源 SAM3(Segment Anything Model 3)能力之上,封装了面向业务的 **“分割一切”** 推理服务:通过 **FastAPI** 提供文本提示词驱动的图像分割接口,并扩展了 **塔罗牌分割/识别**、**人脸与头发分割 + 属性分析** 等场景能力。
|
本项目在开源 SAM3(Segment Anything Model 3)能力之上,封装了面向业务的 **“分割一切”** 推理服务:通过 **FastAPI** 提供文本提示词驱动的图像分割接口,并扩展了 **塔罗牌分割/识别**、**人脸与头发分割 + 属性分析** 等场景能力。
|
||||||
|
|
||||||
本仓库定位为:**模型推理 + API 服务** 的可复用工程模板(适合在 MacOS 开发、服务器部署)。
|
本仓库定位为:**模型推理 + API 服务** 的可复用工程模板(适合在 MacOS 开发、服务器部署)。
|
||||||
|
|||||||
138
fastAPI_tarot.py
138
fastAPI_tarot.py
@@ -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
|
||||||
@@ -72,7 +73,7 @@ HISTORY_FILE = "history.json"
|
|||||||
# Dashscope (Qwen-VL) 配置
|
# Dashscope (Qwen-VL) 配置
|
||||||
dashscope.api_key = 'sk-ce2404f55f744a1987d5ece61c6bac58'
|
dashscope.api_key = 'sk-ce2404f55f744a1987d5ece61c6bac58'
|
||||||
QWEN_MODEL = 'qwen-vl-max' # Default model
|
QWEN_MODEL = 'qwen-vl-max' # Default model
|
||||||
AVAILABLE_QWEN_MODELS = ["qwen-vl-max", "qwen-vl-plus"]
|
AVAILABLE_QWEN_MODELS = ["qwen-vl-max", "qwen-vl-plus","qwen3.5-plus"]
|
||||||
|
|
||||||
# 清理配置 (Cleanup Config)
|
# 清理配置 (Cleanup Config)
|
||||||
CLEANUP_CONFIG = {
|
CLEANUP_CONFIG = {
|
||||||
@@ -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)
|
||||||
|
# 创建异步任务
|
||||||
# 调用 Qwen-VL 识别 (串行)
|
# 使用 lambda 来延迟调用,确保参数传递正确
|
||||||
recognition_res = recognize_card_with_qwen(file_path)
|
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}"))
|
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,14 +1153,26 @@ async def segment_face(
|
|||||||
|
|
||||||
# 调用独立服务进行处理
|
# 调用独立服务进行处理
|
||||||
try:
|
try:
|
||||||
result = human_analysis_service.process_face_segmentation_and_analysis(
|
# 使用新增加的异步并发函数
|
||||||
processor=processor,
|
if hasattr(human_analysis_service, "process_face_segmentation_and_analysis_async"):
|
||||||
image=image,
|
result = await human_analysis_service.process_face_segmentation_and_analysis_async(
|
||||||
prompt=final_prompt,
|
processor=processor,
|
||||||
output_base_dir=RESULT_IMAGE_DIR,
|
image=image,
|
||||||
qwen_model=QWEN_MODEL,
|
prompt=final_prompt,
|
||||||
analysis_prompt=PROMPTS["face_analysis"]
|
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:
|
except Exception as e:
|
||||||
import traceback
|
import traceback
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
|||||||
@@ -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"
|
||||||
|
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"
|
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. 识别
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -135,16 +135,29 @@
|
|||||||
<!-- Main App Interface -->
|
<!-- Main App Interface -->
|
||||||
<div v-if="isLoggedIn" class="flex flex-1 h-full overflow-hidden bg-slate-50">
|
<div v-if="isLoggedIn" class="flex flex-1 h-full overflow-hidden bg-slate-50">
|
||||||
|
|
||||||
|
<!-- Mobile Overlay -->
|
||||||
|
<transition name="fade">
|
||||||
|
<div v-if="isSidebarOpen" @click="isSidebarOpen = false" class="fixed inset-0 bg-slate-900/50 z-40 lg:hidden backdrop-blur-sm"></div>
|
||||||
|
</transition>
|
||||||
|
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
<aside class="w-72 bg-white border-r border-slate-200 flex flex-col shadow-sm z-10">
|
<aside :class="[
|
||||||
<div class="p-8 pb-4">
|
'fixed inset-y-0 left-0 z-50 w-72 bg-white border-r border-slate-200 flex flex-col shadow-sm transition-transform duration-300 ease-in-out lg:static lg:translate-x-0',
|
||||||
|
isSidebarOpen ? 'translate-x-0' : '-translate-x-full'
|
||||||
|
]">
|
||||||
|
<!-- Close Button (Mobile Only) -->
|
||||||
|
<button @click="isSidebarOpen = false" class="absolute top-4 right-4 lg:hidden text-slate-400 hover:text-slate-600 p-2 rounded-full hover:bg-slate-100 transition-colors z-20">
|
||||||
|
<i class="fas fa-times text-lg"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="p-8 pb-4 relative">
|
||||||
<div class="flex items-center gap-3 text-slate-800 mb-8">
|
<div class="flex items-center gap-3 text-slate-800 mb-8">
|
||||||
<div class="w-10 h-10 bg-gradient-to-br from-blue-600 to-indigo-600 rounded-lg flex items-center justify-center shadow-md shadow-blue-200">
|
<div class="w-10 h-10 bg-gradient-to-br from-blue-600 to-indigo-600 rounded-lg flex items-center justify-center shadow-md shadow-blue-200">
|
||||||
<i class="fas fa-layer-group text-white text-lg"></i>
|
<i class="fas fa-layer-group text-white text-lg"></i>
|
||||||
</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">Quantum Track AI</p>
|
<p class="text-xs text-slate-400 font-bold tracking-widest uppercase mt-0.5">Quantum Track AI</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -155,6 +168,16 @@
|
|||||||
<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('segment')" :class="{ 'active': currentTab === 'segment' }"
|
||||||
|
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-crop-alt w-5 text-center transition-colors group-hover:text-blue-600" :class="currentTab === 'segment' ? '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>
|
||||||
@@ -198,10 +221,15 @@
|
|||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
<main class="flex-1 overflow-y-auto overflow-x-hidden relative scroll-smooth">
|
<main class="flex-1 overflow-y-auto overflow-x-hidden relative scroll-smooth">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<header class="sticky top-0 z-20 bg-white/80 backdrop-blur-md border-b border-slate-200 px-8 py-4 flex justify-between items-center shadow-sm">
|
<header class="sticky top-0 z-20 bg-white/80 backdrop-blur-md border-b border-slate-200 px-4 md:px-8 py-4 flex justify-between items-center shadow-sm transition-all">
|
||||||
<div>
|
<div class="flex items-center gap-3">
|
||||||
<h1 class="text-2xl font-bold text-slate-800">{{ getPageTitle(currentTab) }}</h1>
|
<button @click="isSidebarOpen = true" class="lg:hidden p-2 -ml-2 text-slate-500 hover:text-blue-600 hover:bg-slate-100 rounded-lg transition-colors active:scale-95">
|
||||||
<p class="text-sm text-slate-500 mt-0.5">{{ getPageSubtitle(currentTab) }}</p>
|
<i class="fas fa-bars text-xl"></i>
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-xl md:text-2xl font-bold text-slate-800 transition-all">{{ getPageTitle(currentTab) }}</h1>
|
||||||
|
<p class="text-xs md:text-sm text-slate-500 mt-0.5 hidden sm:block">{{ getPageSubtitle(currentTab) }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<div class="bg-white border border-slate-200 rounded-full px-4 py-1.5 flex items-center gap-2 shadow-sm">
|
<div class="bg-white border border-slate-200 rounded-full px-4 py-1.5 flex items-center gap-2 shadow-sm">
|
||||||
@@ -211,13 +239,13 @@
|
|||||||
</span>
|
</span>
|
||||||
<span class="text-sm font-medium text-slate-600">系统正常</span>
|
<span class="text-sm font-medium text-slate-600">系统正常</span>
|
||||||
</div>
|
</div>
|
||||||
<button @click="refreshData" class="p-2.5 text-slate-400 hover:text-blue-600 transition-colors rounded-full hover:bg-slate-100 active:scale-95" title="刷新数据">
|
<button @click="refreshData" class="p-3 text-slate-400 hover:text-blue-600 transition-colors rounded-full hover:bg-slate-100 active:scale-95" title="刷新数据">
|
||||||
<i class="fas fa-sync-alt" :class="{'fa-spin': isLoading}"></i>
|
<i class="fas fa-sync-alt" :class="{'fa-spin': isLoading}"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="p-8 max-w-7xl mx-auto min-h-[calc(100vh-88px)]">
|
<div class="p-4 md:p-8 max-w-7xl mx-auto min-h-[calc(100vh-88px)] transition-all">
|
||||||
<transition name="slide-up" mode="out-in">
|
<transition name="slide-up" mode="out-in">
|
||||||
|
|
||||||
<!-- Dashboard Tab -->
|
<!-- Dashboard Tab -->
|
||||||
@@ -320,7 +348,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center text-xs text-slate-400">
|
<div class="flex items-center text-xs text-slate-400">
|
||||||
<span class="bg-purple-100 text-purple-700 px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wide">Qwen-VL Engine</span>
|
<span class="bg-purple-100 text-purple-700 px-2 py-0.5 rounded text-xs font-bold uppercase tracking-wide">Qwen-VL Engine</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -372,6 +400,368 @@
|
|||||||
|
|
||||||
</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-2.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-xs 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>
|
||||||
|
|
||||||
|
<!-- Segment Tab -->
|
||||||
|
<div v-if="currentTab === 'segment'" key="segment" 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-blue-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-segment-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="!segmentFile && !segmentImageUrl" 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="segmentPreview" :src="segmentPreview" 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]">{{ segmentImageUrl }}</span>
|
||||||
|
</div>
|
||||||
|
<button @click.prevent="clearSegmentInput" class="absolute top-2 right-2 bg-white/80 p-2.5 rounded-full hover:bg-white text-slate-600 shadow-sm">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input id="dropzone-segment-file" type="file" class="hidden" accept="image/*" @change="handleSegmentFileChange" />
|
||||||
|
</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="segmentImageUrl" @input="handleSegmentUrlInput"
|
||||||
|
class="bg-slate-50 border border-slate-200 text-slate-900 text-sm rounded-xl focus:ring-blue-500 focus:border-blue-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">Prompt 提示词 <span class="text-red-500">*</span></label>
|
||||||
|
<input type="text" v-model="segmentPrompt"
|
||||||
|
class="bg-slate-50 border border-slate-200 text-slate-900 text-sm rounded-xl focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 outline-none"
|
||||||
|
placeholder="例如: cat, person, red car...">
|
||||||
|
<p class="text-xs text-slate-400 mt-2">支持中英文,后端会自动翻译。多个对象可用逗号分隔。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700 mb-2">置信度阈值 ({{ segmentConfidence }})</label>
|
||||||
|
<input type="range" v-model.number="segmentConfidence" min="0" max="1" step="0.05" class="w-full h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-blue-600">
|
||||||
|
<div class="flex justify-between text-xs text-slate-400 mt-1">
|
||||||
|
<span>0.0</span>
|
||||||
|
<span>1.0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<label class="flex items-center space-x-2 cursor-pointer">
|
||||||
|
<input type="checkbox" v-model="segmentOptions.save_segment_images" class="rounded text-blue-600 focus:ring-blue-500 border-gray-300">
|
||||||
|
<span class="text-sm text-slate-700">保存分割对象</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center space-x-2 cursor-pointer">
|
||||||
|
<input type="checkbox" v-model="segmentOptions.cutout" class="rounded text-blue-600 focus:ring-blue-500 border-gray-300">
|
||||||
|
<span class="text-sm text-slate-700">透明背景裁剪</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center space-x-2 cursor-pointer">
|
||||||
|
<input type="checkbox" v-model="segmentOptions.perspective_correction" class="rounded text-blue-600 focus:ring-blue-500 border-gray-300">
|
||||||
|
<span class="text-sm text-slate-700">透视矫正</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center space-x-2 cursor-pointer">
|
||||||
|
<input type="checkbox" v-model="segmentOptions.highlight" class="rounded text-blue-600 focus:ring-blue-500 border-gray-300">
|
||||||
|
<span class="text-sm text-slate-700">高亮主体</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pt-4">
|
||||||
|
<button @click="performSegment" :disabled="isSegmenting || (!segmentFile && !segmentImageUrl) || !segmentPrompt"
|
||||||
|
class="w-full bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 text-white font-bold py-3 px-4 rounded-xl shadow-lg shadow-blue-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-crop-alt" :class="{'fa-spin': isSegmenting}"></i>
|
||||||
|
{{ isSegmenting ? '正在分割中...' : '开始分割 (Segment)' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Results Section -->
|
||||||
|
<div v-if="segmentResult" class="space-y-6 animate-fade-in">
|
||||||
|
<!-- Status Banner -->
|
||||||
|
<div :class="segmentResult.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="segmentResult.status === 'success' ? 'fas fa-check-circle' : 'fas fa-exclamation-circle'" class="text-xl"></i>
|
||||||
|
<div>
|
||||||
|
<h4 class="font-bold">{{ segmentResult.status === 'success' ? '分割成功' : '分割失败' }}</h4>
|
||||||
|
<p class="text-sm opacity-90">{{ segmentResult.message || (segmentResult.status === 'success' ? '图像分割任务已完成' : '发生未知错误') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Summary Stats Card -->
|
||||||
|
<div v-if="segmentResult.status === 'success'" class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<div class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100 flex items-center gap-4">
|
||||||
|
<div class="w-12 h-12 bg-blue-50 text-blue-600 rounded-xl flex items-center justify-center text-xl">
|
||||||
|
<i class="fas fa-bullseye"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-slate-500 font-medium">检测数量 (Count)</p>
|
||||||
|
<h3 class="text-2xl font-bold text-slate-800">{{ segmentResult.detected_count || 0 }}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100 flex items-center gap-4">
|
||||||
|
<div class="w-12 h-12 bg-purple-50 text-purple-600 rounded-xl flex items-center justify-center text-xl">
|
||||||
|
<i class="fas fa-chart-line"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-slate-500 font-medium">最高置信度 (Max Score)</p>
|
||||||
|
<h3 class="text-2xl font-bold text-slate-800">
|
||||||
|
{{ segmentResult.scores && segmentResult.scores.length > 0 ? (Math.max(...segmentResult.scores) * 100).toFixed(1) + '%' : '-' }}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100 flex items-center gap-4">
|
||||||
|
<div class="w-12 h-12 bg-orange-50 text-orange-600 rounded-xl flex items-center justify-center text-xl">
|
||||||
|
<i class="fas fa-layer-group"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-slate-500 font-medium">平均置信度 (Avg)</p>
|
||||||
|
<h3 class="text-2xl font-bold text-slate-800">
|
||||||
|
{{ segmentResult.scores && segmentResult.scores.length > 0 ? ((segmentResult.scores.reduce((a,b)=>a+b,0) / segmentResult.scores.length) * 100).toFixed(1) + '%' : '-' }}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Result Visualization -->
|
||||||
|
<div v-if="segmentResult.result_image_url || segmentResult.visualization_url" 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 bg-slate-50 cursor-pointer relative group"
|
||||||
|
@click="previewImage(segmentResult.result_image_url || segmentResult.visualization_url)">
|
||||||
|
<img :src="segmentResult.result_image_url || segmentResult.visualization_url" class="w-full h-auto object-contain max-h-[600px] mx-auto">
|
||||||
|
<div class="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors flex items-center justify-center opacity-0 group-hover:opacity-100">
|
||||||
|
<span class="bg-black/60 text-white px-4 py-2 rounded-full text-sm font-medium backdrop-blur-sm">
|
||||||
|
<i class="fas fa-search-plus mr-2"></i>点击预览大图
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="segmentResult.detected_count === 0" class="mt-4 p-4 bg-yellow-50 text-yellow-700 rounded-xl text-sm border border-yellow-100 flex items-start gap-3">
|
||||||
|
<i class="fas fa-info-circle mt-0.5"></i>
|
||||||
|
<div>
|
||||||
|
<p class="font-bold">未检测到目标</p>
|
||||||
|
<p class="opacity-90 mt-1">当前 Prompt 可能未匹配到图像中的任何物体,或者置信度阈值 ({{ segmentConfidence }}) 设置过高。建议尝试降低阈值或更换 Prompt。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scores List -->
|
||||||
|
<div v-if="segmentResult.scores && segmentResult.scores.length > 0" 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-list-ol text-green-500"></i>
|
||||||
|
检测详情 (Scores)
|
||||||
|
</h3>
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
<div v-for="(score, index) in segmentResult.scores" :key="index"
|
||||||
|
class="bg-slate-50 border border-slate-200 rounded-lg px-3 py-2 flex flex-col items-center min-w-[80px]">
|
||||||
|
<span class="text-xs text-slate-400 font-mono mb-1">#{{ index + 1 }}</span>
|
||||||
|
<span class="text-lg font-bold text-slate-800 font-mono" :class="score > 0.8 ? 'text-green-600' : (score > 0.5 ? 'text-blue-600' : 'text-slate-600')">
|
||||||
|
{{ (score * 100).toFixed(1) }}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Segmented Objects Grid (Optional) -->
|
||||||
|
<div v-if="segmentResult.segments && segmentResult.segments.length > 0" class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||||
|
<div v-for="(seg, index) in segmentResult.segments" :key="index" class="bg-white rounded-xl shadow-sm border border-slate-100 overflow-hidden hover:shadow-md transition-all group">
|
||||||
|
<div class="relative aspect-square bg-slate-100 overflow-hidden cursor-pointer" @click="seg.image_url ? previewImage(seg.image_url) : null">
|
||||||
|
<img v-if="seg.image_url" :src="seg.image_url" class="w-full h-full object-contain p-2 group-hover:scale-110 transition-transform duration-500">
|
||||||
|
<div v-else class="w-full h-full flex items-center justify-center text-slate-300">
|
||||||
|
<i class="fas fa-image text-2xl"></i>
|
||||||
|
</div>
|
||||||
|
<div class="absolute top-1 right-1 bg-black/60 text-white text-[10px] font-bold px-1.5 py-0.5 rounded">
|
||||||
|
{{ (seg.score * 100).toFixed(0) }}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-2 text-center">
|
||||||
|
<p class="text-xs font-medium text-slate-700 truncate" :title="seg.label">{{ seg.label }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- JSON Result -->
|
||||||
|
<div 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-code text-slate-500"></i>
|
||||||
|
JSON 原始数据
|
||||||
|
</h3>
|
||||||
|
<pre class="bg-slate-50 text-slate-700 p-4 rounded-xl text-xs font-mono overflow-x-auto border border-slate-200 max-h-[300px]">{{ JSON.stringify(segmentResult, null, 2) }}</pre>
|
||||||
|
</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">
|
||||||
@@ -404,7 +794,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="record.final_prompt && record.final_prompt !== record.prompt" class="text-xs text-slate-500 bg-slate-50 p-1.5 rounded border border-slate-100">
|
<div v-if="record.final_prompt && record.final_prompt !== record.prompt" class="text-xs text-slate-500 bg-slate-50 p-1.5 rounded border border-slate-100">
|
||||||
<div class="flex items-center gap-1 mb-0.5 text-purple-600 font-medium">
|
<div class="flex items-center gap-1 mb-0.5 text-purple-600 font-medium">
|
||||||
<i class="fas fa-magic text-[10px]"></i> 优化后 Prompt
|
<i class="fas fa-magic text-xs"></i> 优化后 Prompt
|
||||||
</div>
|
</div>
|
||||||
<div class="italic line-clamp-2" :title="record.final_prompt">{{ record.final_prompt }}</div>
|
<div class="italic line-clamp-2" :title="record.final_prompt">{{ record.final_prompt }}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -424,7 +814,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 text-center">
|
<td class="px-6 py-4 text-center">
|
||||||
<button v-if="record.result_path" @click="viewResult(record.result_path)"
|
<button v-if="record.result_path" @click="viewResult(record.result_path)"
|
||||||
class="text-blue-600 hover:text-blue-800 hover:bg-blue-50 p-2 rounded-lg transition-all" title="查看结果">
|
class="text-blue-600 hover:text-blue-800 hover:bg-blue-50 p-3 rounded-lg transition-all" title="查看结果">
|
||||||
<i class="fas fa-external-link-alt"></i>
|
<i class="fas fa-external-link-alt"></i>
|
||||||
</button>
|
</button>
|
||||||
<span v-else class="text-slate-300 cursor-not-allowed"><i class="fas fa-eye-slash"></i></span>
|
<span v-else class="text-slate-300 cursor-not-allowed"><i class="fas fa-eye-slash"></i></span>
|
||||||
@@ -459,7 +849,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button @click="fetchFiles" class="p-2 text-slate-400 hover:text-blue-600 transition-colors rounded hover:bg-slate-50">
|
<button @click="fetchFiles" class="p-3 text-slate-400 hover:text-blue-600 transition-colors rounded hover:bg-slate-50">
|
||||||
<i class="fas fa-sync-alt"></i>
|
<i class="fas fa-sync-alt"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -473,7 +863,7 @@
|
|||||||
<div v-if="file.is_dir" @click="enterDir(file.name)" class="flex flex-col items-center w-full h-full justify-center">
|
<div v-if="file.is_dir" @click="enterDir(file.name)" class="flex flex-col items-center w-full h-full justify-center">
|
||||||
<i class="fas fa-folder text-yellow-400 text-5xl mb-2 drop-shadow-sm group-hover:scale-110 transition-transform"></i>
|
<i class="fas fa-folder text-yellow-400 text-5xl mb-2 drop-shadow-sm group-hover:scale-110 transition-transform"></i>
|
||||||
<span class="text-xs font-medium text-slate-700 truncate w-full text-center px-2">{{ file.name }}</span>
|
<span class="text-xs font-medium text-slate-700 truncate w-full text-center px-2">{{ file.name }}</span>
|
||||||
<span class="text-[10px] text-slate-400">{{ file.count }} 项</span>
|
<span class="text-xs text-slate-400">{{ file.count }} 项</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Image -->
|
<!-- Image -->
|
||||||
@@ -482,19 +872,19 @@
|
|||||||
<img :src="file.url" class="w-full h-full object-contain" loading="lazy">
|
<img :src="file.url" class="w-full h-full object-contain" loading="lazy">
|
||||||
</div>
|
</div>
|
||||||
<span class="text-xs font-medium text-slate-700 truncate w-full text-center px-1">{{ file.name }}</span>
|
<span class="text-xs font-medium text-slate-700 truncate w-full text-center px-1">{{ file.name }}</span>
|
||||||
<span class="text-[10px] text-slate-400 font-mono mt-0.5 bg-slate-100 px-1.5 rounded">{{ formatBytes(file.size) }}</span>
|
<span class="text-xs text-slate-400 font-mono mt-0.5 bg-slate-100 px-1.5 rounded">{{ formatBytes(file.size) }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Other -->
|
<!-- Other -->
|
||||||
<div v-else class="flex flex-col items-center w-full h-full justify-center">
|
<div v-else class="flex flex-col items-center w-full h-full justify-center">
|
||||||
<i class="fas fa-file-alt text-slate-300 text-4xl mb-2"></i>
|
<i class="fas fa-file-alt text-slate-300 text-4xl mb-2"></i>
|
||||||
<span class="text-xs font-medium text-slate-700 truncate w-full text-center px-1">{{ file.name }}</span>
|
<span class="text-xs font-medium text-slate-700 truncate w-full text-center px-1">{{ file.name }}</span>
|
||||||
<span class="text-[10px] text-slate-400 font-mono mt-0.5 bg-slate-100 px-1.5 rounded">{{ formatBytes(file.size) }}</span>
|
<span class="text-xs text-slate-400 font-mono mt-0.5 bg-slate-100 px-1.5 rounded">{{ formatBytes(file.size) }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Delete Action -->
|
<!-- Delete Action -->
|
||||||
<button @click.stop="deleteFile(file.name)"
|
<button @click.stop="deleteFile(file.name)"
|
||||||
class="absolute top-2 right-2 w-6 h-6 bg-red-500 text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all transform scale-90 group-hover:scale-100 hover:bg-red-600 shadow-sm z-10">
|
class="absolute top-2 right-2 w-8 h-8 bg-red-500 text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all transform scale-90 group-hover:scale-100 hover:bg-red-600 shadow-sm z-10">
|
||||||
<i class="fas fa-times text-xs"></i>
|
<i class="fas fa-times text-xs"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -770,6 +1160,7 @@
|
|||||||
setup() {
|
setup() {
|
||||||
// State
|
// State
|
||||||
const isLoggedIn = ref(false);
|
const isLoggedIn = ref(false);
|
||||||
|
const isSidebarOpen = ref(false);
|
||||||
const password = ref('');
|
const password = ref('');
|
||||||
const loginError = ref('');
|
const loginError = ref('');
|
||||||
const currentTab = ref('dashboard');
|
const currentTab = ref('dashboard');
|
||||||
@@ -787,7 +1178,30 @@
|
|||||||
const gpuStatus = ref({});
|
const gpuStatus = ref({});
|
||||||
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);
|
||||||
|
|
||||||
|
// Segment State
|
||||||
|
const segmentFile = ref(null);
|
||||||
|
const segmentImageUrl = ref('');
|
||||||
|
const segmentPreview = ref(null);
|
||||||
|
const segmentPrompt = ref('');
|
||||||
|
const segmentConfidence = ref(0.7);
|
||||||
|
const segmentOptions = ref({
|
||||||
|
save_segment_images: false,
|
||||||
|
cutout: false,
|
||||||
|
perspective_correction: false,
|
||||||
|
highlight: false
|
||||||
|
});
|
||||||
|
const segmentResult = ref(null);
|
||||||
|
const isSegmenting = ref(false);
|
||||||
|
|
||||||
// Filters
|
// Filters
|
||||||
const selectedTimeRange = ref('all');
|
const selectedTimeRange = ref('all');
|
||||||
const selectedType = ref('all');
|
const selectedType = ref('all');
|
||||||
@@ -994,12 +1408,12 @@
|
|||||||
|
|
||||||
const startGpuMonitoring = () => {
|
const startGpuMonitoring = () => {
|
||||||
if (gpuInterval) clearInterval(gpuInterval);
|
if (gpuInterval) clearInterval(gpuInterval);
|
||||||
// Use setTimeout to ensure DOM is ready after v-if switch
|
// Use setTimeout to ensure DOM is ready after v-if switch (wait for slide-up transition ~700ms)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
initGpuCharts();
|
initGpuCharts();
|
||||||
fetchGpuStatus(); // Initial fetch
|
fetchGpuStatus(); // Initial fetch
|
||||||
gpuInterval = setInterval(fetchGpuStatus, 2000); // Every 2s
|
gpuInterval = setInterval(fetchGpuStatus, 2000); // Every 2s
|
||||||
}, 300);
|
}, 800);
|
||||||
};
|
};
|
||||||
|
|
||||||
const stopGpuMonitoring = () => {
|
const stopGpuMonitoring = () => {
|
||||||
@@ -1007,6 +1421,9 @@
|
|||||||
clearInterval(gpuInterval);
|
clearInterval(gpuInterval);
|
||||||
gpuInterval = null;
|
gpuInterval = null;
|
||||||
}
|
}
|
||||||
|
// Dispose charts to free memory and avoid resize issues on re-init
|
||||||
|
if (gpuUtilChartInst) { gpuUtilChartInst.dispose(); gpuUtilChartInst = null; }
|
||||||
|
if (gpuTempChartInst) { gpuTempChartInst.dispose(); gpuTempChartInst = null; }
|
||||||
};
|
};
|
||||||
|
|
||||||
const initGpuCharts = () => {
|
const initGpuCharts = () => {
|
||||||
@@ -1051,11 +1468,20 @@
|
|||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Explicitly resize to ensure correct rendering
|
||||||
|
gpuUtilChartInst.resize();
|
||||||
|
gpuTempChartInst.resize();
|
||||||
|
|
||||||
// Render initial data if available
|
// Render initial data if available
|
||||||
updateGpuCharts();
|
updateGpuCharts();
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateGpuCharts = () => {
|
const updateGpuCharts = () => {
|
||||||
|
// Lazy init check
|
||||||
|
if ((!gpuUtilChartInst || !gpuTempChartInst) && gpuUtilChartRef.value && gpuTempChartRef.value) {
|
||||||
|
initGpuCharts();
|
||||||
|
}
|
||||||
|
|
||||||
if (!gpuUtilChartInst || !gpuTempChartInst) return;
|
if (!gpuUtilChartInst || !gpuTempChartInst) return;
|
||||||
|
|
||||||
const times = gpuHistory.value.map(h => h.time);
|
const times = gpuHistory.value.map(h => h.time);
|
||||||
@@ -1135,10 +1561,167 @@
|
|||||||
} 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);
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
headers: {
|
||||||
|
'X-API-Key': '123quant-speed'
|
||||||
|
},
|
||||||
|
timeout: 120000
|
||||||
|
};
|
||||||
|
|
||||||
|
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 || '识别请求失败';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Segment Actions ---
|
||||||
|
const handleSegmentFileChange = (event) => {
|
||||||
|
const file = event.target.files[0];
|
||||||
|
if (file) {
|
||||||
|
segmentFile.value = file;
|
||||||
|
segmentImageUrl.value = '';
|
||||||
|
segmentPreview.value = URL.createObjectURL(file);
|
||||||
|
segmentResult.value = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSegmentUrlInput = () => {
|
||||||
|
if (segmentImageUrl.value) {
|
||||||
|
segmentFile.value = null;
|
||||||
|
segmentPreview.value = segmentImageUrl.value;
|
||||||
|
segmentResult.value = null;
|
||||||
|
} else {
|
||||||
|
segmentPreview.value = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearSegmentInput = () => {
|
||||||
|
segmentFile.value = null;
|
||||||
|
segmentImageUrl.value = '';
|
||||||
|
segmentPreview.value = null;
|
||||||
|
segmentResult.value = null;
|
||||||
|
const fileInput = document.getElementById('dropzone-segment-file');
|
||||||
|
if (fileInput) fileInput.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const performSegment = async () => {
|
||||||
|
if ((!segmentFile.value && !segmentImageUrl.value) || !segmentPrompt.value) return;
|
||||||
|
|
||||||
|
isSegmenting.value = true;
|
||||||
|
segmentResult.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
if (segmentFile.value) {
|
||||||
|
formData.append('file', segmentFile.value);
|
||||||
|
} else {
|
||||||
|
formData.append('image_url', segmentImageUrl.value);
|
||||||
|
}
|
||||||
|
formData.append('prompt', segmentPrompt.value);
|
||||||
|
formData.append('confidence', segmentConfidence.value);
|
||||||
|
formData.append('save_segment_images', segmentOptions.value.save_segment_images);
|
||||||
|
formData.append('cutout', segmentOptions.value.cutout);
|
||||||
|
formData.append('perspective_correction', segmentOptions.value.perspective_correction);
|
||||||
|
formData.append('highlight', segmentOptions.value.highlight);
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
headers: {
|
||||||
|
'X-API-Key': '123quant-speed'
|
||||||
|
},
|
||||||
|
timeout: 120000
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await axios.post('/segment', formData, config);
|
||||||
|
segmentResult.value = res.data;
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
let msg = e.response?.data?.detail || e.message || '分割请求失败';
|
||||||
|
|
||||||
|
if (e.response && e.response.status === 504) {
|
||||||
|
msg = '请求超时 (504):处理时间较长。';
|
||||||
|
} else if (e.code === 'ECONNABORTED') {
|
||||||
|
msg = '请求超时:网络连接中断或服务器响应过慢。';
|
||||||
|
}
|
||||||
|
|
||||||
|
segmentResult.value = {
|
||||||
|
status: 'failed',
|
||||||
|
message: msg
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
isSegmenting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// --- Navigation & Helpers ---
|
// --- Navigation & Helpers ---
|
||||||
const switchTab = (tab) => {
|
const switchTab = (tab) => {
|
||||||
const prevTab = currentTab.value;
|
const prevTab = currentTab.value;
|
||||||
currentTab.value = tab;
|
currentTab.value = tab;
|
||||||
|
isSidebarOpen.value = false; // Close sidebar on mobile
|
||||||
|
|
||||||
// GPU Monitor Logic
|
// GPU Monitor Logic
|
||||||
if (tab === 'gpu') {
|
if (tab === 'gpu') {
|
||||||
@@ -1224,6 +1807,8 @@
|
|||||||
const getPageTitle = (tab) => {
|
const getPageTitle = (tab) => {
|
||||||
const map = {
|
const map = {
|
||||||
'dashboard': '数据看板',
|
'dashboard': '数据看板',
|
||||||
|
'tarot': '塔罗牌识别',
|
||||||
|
'segment': '通用分割',
|
||||||
'history': '识别记录',
|
'history': '识别记录',
|
||||||
'files': '文件资源管理',
|
'files': '文件资源管理',
|
||||||
'prompts': '提示词工程',
|
'prompts': '提示词工程',
|
||||||
@@ -1236,6 +1821,8 @@
|
|||||||
const getPageSubtitle = (tab) => {
|
const getPageSubtitle = (tab) => {
|
||||||
const map = {
|
const map = {
|
||||||
'dashboard': '系统运行状态与核心指标概览',
|
'dashboard': '系统运行状态与核心指标概览',
|
||||||
|
'tarot': 'SAM3 + Qwen-VL 联合识别与分割',
|
||||||
|
'segment': '基于文本提示的通用图像分割 (Grounded SAM)',
|
||||||
'history': '所有视觉识别任务的历史流水',
|
'history': '所有视觉识别任务的历史流水',
|
||||||
'files': '查看和管理生成的图像及JSON结果',
|
'files': '查看和管理生成的图像及JSON结果',
|
||||||
'prompts': '调整各个识别场景的 System Prompt',
|
'prompts': '调整各个识别场景的 System Prompt',
|
||||||
@@ -1433,7 +2020,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isLoggedIn, password, loginError, login, logout,
|
isLoggedIn, isSidebarOpen, password, loginError, login, logout,
|
||||||
currentTab, switchTab, history, files, currentPath,
|
currentTab, switchTab, history, files, currentPath,
|
||||||
enterDir, navigateUp, deleteFile, triggerCleanup,
|
enterDir, navigateUp, deleteFile, triggerCleanup,
|
||||||
viewResult, previewImage, isImage, previewUrl,
|
viewResult, previewImage, isImage, previewUrl,
|
||||||
@@ -1448,7 +2035,13 @@
|
|||||||
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,
|
||||||
|
// Segment
|
||||||
|
segmentFile, segmentImageUrl, segmentPreview, segmentPrompt, segmentConfidence, segmentOptions, segmentResult, isSegmenting,
|
||||||
|
handleSegmentFileChange, handleSegmentUrlInput, clearSegmentInput, performSegment
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}).mount('#app');
|
}).mount('#app');
|
||||||
|
|||||||
Reference in New Issue
Block a user