Compare commits

...

6 Commits

Author SHA1 Message Date
53e8fbb4dd 通用分割 2026-02-18 16:55:17 +08:00
f7c73fa57e 通用分割 2026-02-18 16:54:52 +08:00
bad6bfa34b 优化手机端 2026-02-18 16:48:48 +08:00
054e720e39 优化手机端 2026-02-18 14:50:21 +08:00
f8e94328a7 qwen3.5 优化 2026-02-18 14:39:45 +08:00
aee6f8804f qwen3.5 优化 2026-02-18 14:38:12 +08:00
3 changed files with 969 additions and 72 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)
# 创建异步任务
# 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()

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"
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
} }

View File

@@ -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">Quant Speed 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">
@@ -383,7 +773,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">
@@ -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');
@@ -1147,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') {
@@ -1236,6 +1807,8 @@
const getPageTitle = (tab) => { const getPageTitle = (tab) => {
const map = { const map = {
'dashboard': '数据看板', 'dashboard': '数据看板',
'tarot': '塔罗牌识别',
'segment': '通用分割',
'history': '识别记录', 'history': '识别记录',
'files': '文件资源管理', 'files': '文件资源管理',
'prompts': '提示词工程', 'prompts': '提示词工程',
@@ -1248,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',
@@ -1445,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,
@@ -1460,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');