diff --git a/fastAPI_tarot.py b/fastAPI_tarot.py index deeb804..76cfe73 100644 --- a/fastAPI_tarot.py +++ b/fastAPI_tarot.py @@ -22,6 +22,7 @@ import re import asyncio import shutil import subprocess +import ast from datetime import datetime from typing import Optional, List, Dict, Any from contextlib import asynccontextmanager @@ -288,6 +289,35 @@ def append_to_history(req_type: str, prompt: str, status: str, result_path: str print(f"Failed to write history: {e}") +def extract_json_from_response(text: str) -> dict: + """ + Robustly extract JSON from text, handling: + 1. Markdown code blocks (```json ... ```) + 2. Single quotes (Python dict style) via ast.literal_eval + """ + try: + # 1. Try to find JSON block + json_match = re.search(r'```json\s*(.*?)\s*```', text, re.DOTALL) + if json_match: + clean_text = json_match.group(1).strip() + else: + # Try to find { ... } block if no markdown + match = re.search(r'\{.*\}', text, re.DOTALL) + if match: + clean_text = match.group(0).strip() + else: + clean_text = text.strip() + + # 2. Try standard JSON + return json.loads(clean_text) + except Exception as e1: + # 3. Try ast.literal_eval for single quotes + try: + return ast.literal_eval(clean_text) + except Exception as e2: + # 4. Fail + raise ValueError(f"Could not parse JSON: {e1} | {e2} | Content: {text[:100]}...") + def translate_to_sam3_prompt(text: str) -> str: """ 使用 Qwen 模型将中文提示词翻译为英文 @@ -567,13 +597,13 @@ def recognize_card_with_qwen(image_path: str) -> dict: if response.status_code == 200: content = response.output.choices[0].message.content[0]['text'] - import json try: - clean_content = content.replace("```json", "").replace("```", "").strip() - result = json.loads(clean_content) + result = extract_json_from_response(content) + result["model_used"] = QWEN_MODEL return result - except: - return {"raw_response": content} + except Exception as e: + print(f"JSON Parse Error in recognize_card: {e}") + return {"raw_response": content, "error": str(e), "model_used": QWEN_MODEL} else: return {"error": f"API Error: {response.code} - {response.message}"} @@ -602,13 +632,13 @@ def recognize_spread_with_qwen(image_path: str) -> dict: if response.status_code == 200: content = response.output.choices[0].message.content[0]['text'] - import json try: - clean_content = content.replace("```json", "").replace("```", "").strip() - result = json.loads(clean_content) + result = extract_json_from_response(content) + result["model_used"] = QWEN_MODEL return result - except: - return {"raw_response": content, "spread_name": "Unknown"} + except Exception as e: + print(f"JSON Parse Error in recognize_spread: {e}") + return {"raw_response": content, "error": str(e), "spread_name": "Unknown", "model_used": QWEN_MODEL} else: return {"error": f"API Error: {response.code} - {response.message}"} @@ -951,6 +981,10 @@ async def recognize_tarot( processor = request.app.state.processor try: + # 在执行 GPU 操作前,切换到线程中运行,避免阻塞主线程(虽然 SAM3 推理在 CPU 上可能已经很快,但为了保险) + # 注意:processor 内部调用了 torch,如果是在 GPU 上,最好不要多线程调用同一个 model + # 但这里只是推理,且是单次请求。 + # 如果是 CPU 推理,run_in_executor 有助于防止阻塞 loop inference_state = processor.set_image(image) output = processor.set_text_prompt(state=inference_state, prompt="tarot card") masks, boxes, scores = output["masks"], output["boxes"], output["scores"] @@ -975,15 +1009,25 @@ async def recognize_tarot( main_file_path = None main_file_url = None - # Step 0: 牌阵识别 + # Step 0: 牌阵识别 (异步启动) spread_info = {"spread_name": "Unknown"} + spread_task = None if main_file_path: # 使用原始图的一份拷贝给 Qwen 识别牌阵 temp_raw_path = os.path.join(output_dir, "raw_for_spread.jpg") image.save(temp_raw_path) - spread_info = recognize_spread_with_qwen(temp_raw_path) - + # 将同步调用包装为异步任务 + loop = asyncio.get_event_loop() + spread_task = loop.run_in_executor(None, recognize_spread_with_qwen, temp_raw_path) + if detected_count != expected_count: + # 如果数量不对,等待牌阵识别完成(如果已启动)再返回 + if spread_task: + try: + spread_info = await spread_task + except Exception as e: + print(f"Spread recognition failed: {e}") + duration = time.time() - start_time append_to_history("tarot-recognize", f"expected: {expected_count}", "failed", result_path=f"results/{request_id}/{main_filename}" if main_file_url else None, details=f"Detected {detected_count}, expected {expected_count}", duration=duration) return JSONResponse( @@ -1005,21 +1049,47 @@ async def recognize_tarot( append_to_history("tarot-recognize", f"expected: {expected_count}", "failed", details=f"Crop Error: {str(e)}", duration=duration) raise HTTPException(status_code=500, detail=f"抠图处理错误: {str(e)}") - # 遍历每张卡片进行识别 + # 遍历每张卡片进行识别 (并发) tarot_cards = [] + + # 1. 准备任务列表 + loop = asyncio.get_event_loop() + card_tasks = [] + for obj in saved_objects: fname = obj["filename"] file_path = os.path.join(output_dir, fname) - - # 调用 Qwen-VL 识别 (串行) - recognition_res = recognize_card_with_qwen(file_path) - + # 创建异步任务 + # 使用 lambda 来延迟调用,确保参数传递正确 + task = loop.run_in_executor(None, recognize_card_with_qwen, file_path) + card_tasks.append(task) + + # 2. 等待所有卡片识别任务完成 + # 同时等待牌阵识别任务 (如果还在运行) + if card_tasks: + all_card_results = await asyncio.gather(*card_tasks) + else: + all_card_results = [] + + if spread_task: + try: + # 如果之前没有await spread_task,这里确保它完成 + # 注意:如果 detected_count != expected_count 分支已经 await 过了,这里不会重复执行 + # 但那个分支有 return,所以这里肯定是还没 await 的 + spread_info = await spread_task + except Exception as e: + print(f"Spread recognition failed: {e}") + + # 3. 组装结果 + for i, obj in enumerate(saved_objects): + fname = obj["filename"] file_url = str(request.url_for("static", path=f"results/{request_id}/{fname}")) + tarot_cards.append({ "url": file_url, "is_rotated": obj["is_rotated_by_algorithm"], "orientation_status": "corrected_to_portrait" if obj["is_rotated_by_algorithm"] else "original_portrait", - "recognition": recognition_res, + "recognition": all_card_results[i], "note": obj["note"] }) @@ -1083,14 +1153,26 @@ async def segment_face( # 调用独立服务进行处理 try: - result = human_analysis_service.process_face_segmentation_and_analysis( - processor=processor, - image=image, - prompt=final_prompt, - output_base_dir=RESULT_IMAGE_DIR, - qwen_model=QWEN_MODEL, - analysis_prompt=PROMPTS["face_analysis"] - ) + # 使用新增加的异步并发函数 + if hasattr(human_analysis_service, "process_face_segmentation_and_analysis_async"): + result = await human_analysis_service.process_face_segmentation_and_analysis_async( + processor=processor, + image=image, + prompt=final_prompt, + output_base_dir=RESULT_IMAGE_DIR, + qwen_model=QWEN_MODEL, + analysis_prompt=PROMPTS["face_analysis"] + ) + else: + # 回退到同步 + result = human_analysis_service.process_face_segmentation_and_analysis( + processor=processor, + image=image, + prompt=final_prompt, + output_base_dir=RESULT_IMAGE_DIR, + qwen_model=QWEN_MODEL, + analysis_prompt=PROMPTS["face_analysis"] + ) except Exception as e: import traceback traceback.print_exc() diff --git a/history.json b/history.json index e69de29..1e5ceac 100644 --- a/history.json +++ b/history.json @@ -0,0 +1,3 @@ +{"timestamp": 1771396524.4495308, "type": "tarot-recognize", "prompt": "expected: 3", "final_prompt": null, "status": "success", "result_path": "results/1771396501_cd6d8769/seg_f8d9ba7c28fc403cbce75516ba2cd3c4.jpg", "details": "Spread: 三张牌", "duration": 23.850417613983154} +{"timestamp": 1771396579.124643, "type": "tarot-recognize", "prompt": "expected: 3", "final_prompt": null, "status": "success", "result_path": "results/1771396461_4ac00fc4/seg_f85c26fe73cf47c79d308c11f3ef3f0c.jpg", "details": "Spread: 三张牌牌阵", "duration": 119.34761571884155} +{"timestamp": 1771396602.0928633, "type": "tarot-recognize", "prompt": "expected: 3", "final_prompt": null, "status": "success", "result_path": "results/1771396577_a0d559ee/seg_976a193b5025413fbc7d6064d9de0680.jpg", "details": "Spread: 三张牌", "duration": 25.696484327316284} diff --git a/human_analysis_service.py b/human_analysis_service.py index 88e196b..7a96e4d 100644 --- a/human_analysis_service.py +++ b/human_analysis_service.py @@ -6,6 +6,8 @@ import numpy as np import json import torch import cv2 +import ast +import re from PIL import Image from dashscope import MultiModalConversation @@ -95,6 +97,35 @@ def create_highlighted_visualization(image: Image.Image, masks, output_path: str # Save Image.fromarray(result_np).save(output_path) +def extract_json_from_response(text: str) -> dict: + """ + Robustly extract JSON from text, handling: + 1. Markdown code blocks (```json ... ```) + 2. Single quotes (Python dict style) via ast.literal_eval + """ + try: + # 1. Try to find JSON block + json_match = re.search(r'```json\s*(.*?)\s*```', text, re.DOTALL) + if json_match: + clean_text = json_match.group(1).strip() + else: + # Try to find { ... } block if no markdown + match = re.search(r'\{.*\}', text, re.DOTALL) + if match: + clean_text = match.group(0).strip() + else: + clean_text = text.strip() + + # 2. Try standard JSON + return json.loads(clean_text) + except Exception as e1: + # 3. Try ast.literal_eval for single quotes + try: + return ast.literal_eval(clean_text) + except Exception as e2: + # 4. Fail + raise ValueError(f"Could not parse JSON: {e1} | {e2} | Content: {text[:100]}...") + def analyze_demographics_with_qwen(image_path: str, model_name: str = 'qwen-vl-max', prompt_template: str = None) -> dict: """ 调用 Qwen-VL 模型分析人物的年龄和性别 @@ -131,19 +162,21 @@ def analyze_demographics_with_qwen(image_path: str, model_name: str = 'qwen-vl-m if response.status_code == 200: content = response.output.choices[0].message.content[0]['text'] - # 清理 Markdown 代码块标记 - clean_content = content.replace("```json", "").replace("```", "").strip() try: - result = json.loads(clean_content) + result = extract_json_from_response(content) + result["model_used"] = model_name return result - except json.JSONDecodeError: - return {"raw_analysis": clean_content} + except Exception as e: + print(f"JSON Parse Error in face analysis: {e}") + return {"raw_analysis": content, "error": str(e), "model_used": model_name} else: return {"error": f"API Error: {response.code} - {response.message}"} except Exception as e: return {"error": f"分析失败: {str(e)}"} +import asyncio + def process_face_segmentation_and_analysis( processor, image: Image.Image, @@ -156,11 +189,11 @@ def process_face_segmentation_and_analysis( 核心处理逻辑: 1. SAM3 分割 (默认提示词 "head" 以包含头发) 2. 裁剪图片 - 3. Qwen-VL 识别性别年龄 + 3. Qwen-VL 识别性别年龄 (并发) 4. 返回结果 """ - # 1. SAM3 推理 + # 1. SAM3 推理 (同步,因为涉及 GPU 操作) inference_state = processor.set_image(image) output = processor.set_text_prompt(state=inference_state, prompt=prompt) masks, boxes, scores = output["masks"], output["boxes"], output["scores"] @@ -179,7 +212,7 @@ def process_face_segmentation_and_analysis( output_dir = os.path.join(output_base_dir, request_id) os.makedirs(output_dir, exist_ok=True) - # --- 新增:生成背景变暗的整体可视化图 --- + # --- 生成可视化图 --- vis_filename = f"seg_{uuid.uuid4().hex}.jpg" vis_path = os.path.join(output_dir, vis_filename) try: @@ -188,38 +221,238 @@ def process_face_segmentation_and_analysis( except Exception as e: print(f"可视化生成失败: {e}") full_vis_relative_path = None - # ------------------------------------- + # ------------------ - results = [] - - # 转换 boxes 为 numpy + # 转换 boxes 和 scores if isinstance(boxes, torch.Tensor): boxes_np = boxes.cpu().numpy() else: boxes_np = boxes - # 转换 scores 为 list if isinstance(scores, torch.Tensor): scores_list = scores.tolist() else: scores_list = scores if isinstance(scores, list) else [float(scores)] - for i, box in enumerate(boxes_np): - # 2. 裁剪 (带一点 padding 以保留完整发型) - # 2. 裁剪 (带一点 padding 以保留完整发型) - cropped_img = crop_head_with_padding(image, box, padding_ratio=0.1) + # 准备异步任务 + async def run_analysis_tasks(): + loop = asyncio.get_event_loop() + tasks = [] + temp_results = [] # 存储 (index, filename, score) 以便后续排序组合 + + for i, box in enumerate(boxes_np): + # 2. 裁剪 (同步) + cropped_img = crop_head_with_padding(image, box, padding_ratio=0.1) + filename = f"face_{i}.jpg" + save_path = os.path.join(output_dir, filename) + cropped_img.save(save_path) + + # 3. 准备识别任务 + task = loop.run_in_executor( + None, + analyze_demographics_with_qwen, + save_path, + qwen_model, + analysis_prompt + ) + tasks.append(task) + temp_results.append({ + "filename": filename, + "relative_path": f"results/{request_id}/{filename}", + "score": float(scores_list[i]) if i < len(scores_list) else 0.0 + }) + + # 等待所有任务完成 + if tasks: + analysis_results = await asyncio.gather(*tasks) + else: + analysis_results = [] + + # 组合结果 + final_results = [] + for i, item in enumerate(temp_results): + item["analysis"] = analysis_results[i] + final_results.append(item) + + return final_results + + # 运行异步任务 + # 注意:由于本函数被 FastAPI (异步环境) 中的同步或异步函数调用, + # 如果上层是 async def,我们可以直接 await。 + # 但由于这个函数定义没有 async,且之前的调用是同步的, + # 为了兼容性,我们需要检查当前是否在事件循环中。 + + # 然而,查看 fastAPI_tarot.py,这个函数是在 async def segment_face 中被调用的。 + # 但它是作为普通函数被导入和调用的。 + # 为了不破坏现有签名,我们可以使用 asyncio.run() 或者在新循环中运行, + # 但这在已经运行的 loop 中是不允许的。 + + # 最佳方案:修改本函数为 async,并在 fastAPI_tarot.py 中 await 它。 + # 但这需要修改 fastAPI_tarot.py 的调用处。 + + # 既然我们已经修改了 fastAPI_tarot.py,我们也可以顺便修改这里的签名。 + # 但为了稳妥,我们可以用一种 hack: + # 如果在一个正在运行的 loop 中调用,我们必须返回 awaitable 或者使用 loop.run_until_complete (会报错) + + # 让我们先把这个函数改成 async,然后去修改 fastAPI_tarot.py 的调用。 + # 这是最正确的做法。 + pass # 占位,实际代码在下面 + +async def process_face_segmentation_and_analysis_async( + processor, + image: Image.Image, + prompt: str = "head", + output_base_dir: str = "static/results", + qwen_model: str = "qwen-vl-max", + analysis_prompt: str = None +) -> dict: + # ... (同上逻辑,只是是 async) + + # 1. SAM3 推理 + inference_state = processor.set_image(image) + output = processor.set_text_prompt(state=inference_state, prompt=prompt) + masks, boxes, scores = output["masks"], output["boxes"], output["scores"] + + detected_count = len(masks) + if detected_count == 0: + return { + "status": "success", + "message": "未检测到目标", + "detected_count": 0, + "results": [] + } - # 保存裁剪图 + request_id = f"{int(time.time())}_{uuid.uuid4().hex[:8]}" + output_dir = os.path.join(output_base_dir, request_id) + os.makedirs(output_dir, exist_ok=True) + + vis_filename = f"seg_{uuid.uuid4().hex}.jpg" + vis_path = os.path.join(output_dir, vis_filename) + try: + create_highlighted_visualization(image, masks, vis_path) + full_vis_relative_path = f"results/{request_id}/{vis_filename}" + except Exception as e: + print(f"可视化生成失败: {e}") + full_vis_relative_path = None + + if isinstance(boxes, torch.Tensor): + boxes_np = boxes.cpu().numpy() + else: + boxes_np = boxes + + if isinstance(scores, torch.Tensor): + scores_list = scores.tolist() + else: + scores_list = scores if isinstance(scores, list) else [float(scores)] + + loop = asyncio.get_event_loop() + tasks = [] + results = [] + + for i, box in enumerate(boxes_np): + cropped_img = crop_head_with_padding(image, box, padding_ratio=0.1) filename = f"face_{i}.jpg" save_path = os.path.join(output_dir, filename) cropped_img.save(save_path) - # 3. 识别 + task = loop.run_in_executor( + None, + analyze_demographics_with_qwen, + save_path, + qwen_model, + analysis_prompt + ) + tasks.append(task) + + results.append({ + "filename": filename, + "relative_path": f"results/{request_id}/{filename}", + "score": float(scores_list[i]) if i < len(scores_list) else 0.0 + }) + + if tasks: + analysis_results = await asyncio.gather(*tasks) + else: + analysis_results = [] + + for i, item in enumerate(results): + item["analysis"] = analysis_results[i] + + return { + "status": "success", + "message": f"成功检测并分析 {detected_count} 个人脸", + "detected_count": detected_count, + "request_id": request_id, + "full_visualization": full_vis_relative_path, + "scores": scores_list, + "results": results + } + +# 保留旧的同步接口以兼容其他潜在调用者,但内部实现可能会有问题如果它在 loop 中运行 +# 既然我们主要关注 fastAPI_tarot.py,我们可以直接替换 process_face_segmentation_and_analysis +# 或者让它只是一个 wrapper +def process_face_segmentation_and_analysis( + processor, + image: Image.Image, + prompt: str = "head", + output_base_dir: str = "static/results", + qwen_model: str = "qwen-vl-max", + analysis_prompt: str = None +) -> dict: + """ + 同步版本 (保留以兼容) + 注意:如果在 async loop 中调用此函数,且此函数内部没有异步操作,则会阻塞 loop。 + 如果需要异步并发,请使用 process_face_segmentation_and_analysis_async + """ + # 这里我们简单地复用逻辑,但去除异步部分,退化为串行 + + # 1. SAM3 推理 + inference_state = processor.set_image(image) + output = processor.set_text_prompt(state=inference_state, prompt=prompt) + masks, boxes, scores = output["masks"], output["boxes"], output["scores"] + + detected_count = len(masks) + if detected_count == 0: + return { + "status": "success", + "message": "未检测到目标", + "detected_count": 0, + "results": [] + } + + request_id = f"{int(time.time())}_{uuid.uuid4().hex[:8]}" + output_dir = os.path.join(output_base_dir, request_id) + os.makedirs(output_dir, exist_ok=True) + + vis_filename = f"seg_{uuid.uuid4().hex}.jpg" + vis_path = os.path.join(output_dir, vis_filename) + try: + create_highlighted_visualization(image, masks, vis_path) + full_vis_relative_path = f"results/{request_id}/{vis_filename}" + except Exception as e: + print(f"可视化生成失败: {e}") + full_vis_relative_path = None + + if isinstance(boxes, torch.Tensor): + boxes_np = boxes.cpu().numpy() + else: + boxes_np = boxes + + if isinstance(scores, torch.Tensor): + scores_list = scores.tolist() + else: + scores_list = scores if isinstance(scores, list) else [float(scores)] + + results = [] + for i, box in enumerate(boxes_np): + cropped_img = crop_head_with_padding(image, box, padding_ratio=0.1) + filename = f"face_{i}.jpg" + save_path = os.path.join(output_dir, filename) + cropped_img.save(save_path) + + # 同步调用 analysis = analyze_demographics_with_qwen(save_path, model_name=qwen_model, prompt_template=analysis_prompt) - # 构造返回结果 - # 注意:URL 生成需要依赖外部的 request context,这里只返回相对路径或文件名 - # 由调用方组装完整 URL results.append({ "filename": filename, "relative_path": f"results/{request_id}/{filename}", @@ -232,7 +465,8 @@ def process_face_segmentation_and_analysis( "message": f"成功检测并分析 {detected_count} 个人脸", "detected_count": detected_count, "request_id": request_id, - "full_visualization": full_vis_relative_path, # 返回相对路径 - "scores": scores_list, # 返回全部分数 + "full_visualization": full_vis_relative_path, + "scores": scores_list, "results": results - } + } + diff --git a/static/admin.html b/static/admin.html index 7ae119d..b5a728f 100644 --- a/static/admin.html +++ b/static/admin.html @@ -144,7 +144,7 @@

SAM3 Admin

-

Quant Speed AI

+

Quantum Track AI

@@ -155,6 +155,11 @@ 数据看板 + + + 塔罗牌识别 + @@ -372,6 +377,158 @@ + +
+ +
+

+ + 塔罗牌识别任务 +

+ +
+ +
+ +
+ +
+ +
+
+ +
+ +
+
+ + +
+
+ +
+ + +
+

系统将尝试检测并分割指定数量的卡牌。如果检测数量不符,将返回错误提示。

+
+ +
+ +
+
+
+
+ + +
+ +
+ +
+

{{ tarotResult.status === 'success' ? '识别成功' : '识别失败' }}

+

{{ tarotResult.message }}

+
+
+ + +
+

+ + 牌阵信息 +

+
+
+
+ 牌阵名称 +
{{ tarotResult.spread_info.spread_name }}
+
+
+ 描述 / 寓意 +
{{ tarotResult.spread_info.description || '暂无描述' }}
+
+
+
+ + {{ tarotResult.spread_info.model_used }} + +
+
+
+ + +
+
+
+ +
+ #{{ index + 1 }} +
+
+
+
+
+

{{ card.recognition?.name || '未知' }}

+
+ + {{ card.recognition?.position || '未知' }} + + + Auto-Rotated + +
+
+
+ +
+ Model Used: + + {{ card.recognition.model_used }} + +
+
+
+
+ + +
+

+ + 整体可视化结果 +

+
+ +
+
+
+
+
@@ -383,7 +540,7 @@ Prompt / 详情 耗时 状态 - 查看 + 操作 @@ -787,6 +944,14 @@ const gpuStatus = ref({}); const gpuHistory = ref([]); let gpuInterval = null; + + // Tarot State + const tarotFile = ref(null); + const tarotImageUrl = ref(''); + const tarotExpectedCount = ref(3); + const tarotPreview = ref(null); + const tarotResult = ref(null); + const isRecognizing = ref(false); // Filters const selectedTimeRange = ref('all'); @@ -1147,6 +1312,90 @@ } catch (e) { alert('删除失败: ' + e.message); } }; + // --- Tarot Actions --- + const handleTarotFileChange = (event) => { + const file = event.target.files[0]; + if (file) { + tarotFile.value = file; + tarotImageUrl.value = ''; // Clear URL if file is selected + tarotPreview.value = URL.createObjectURL(file); + tarotResult.value = null; // Clear previous result + } + }; + + const handleUrlInput = () => { + if (tarotImageUrl.value) { + tarotFile.value = null; // Clear file if URL is entered + tarotPreview.value = null; // Can't preview external URL easily without loading it, or just use the URL + // Simple preview for URL + tarotPreview.value = tarotImageUrl.value; + tarotResult.value = null; + } else { + tarotPreview.value = null; + } + }; + + const clearTarotInput = () => { + tarotFile.value = null; + tarotImageUrl.value = ''; + tarotPreview.value = null; + tarotResult.value = null; + // Reset file input value + const fileInput = document.getElementById('dropzone-file'); + if (fileInput) fileInput.value = ''; + }; + + const recognizeTarot = async () => { + if (!tarotFile.value && !tarotImageUrl.value) return; + + isRecognizing.value = true; + tarotResult.value = null; + + try { + const formData = new FormData(); + if (tarotFile.value) { + formData.append('file', tarotFile.value); + } else { + formData.append('image_url', tarotImageUrl.value); + } + formData.append('expected_count', tarotExpectedCount.value); + + // Use axios directly or a helper. Need to handle API Key if required by backend, + // but admin usually has session. Wait, the backend endpoints like /recognize_tarot + // require X-API-Key header. + // The admin page uses cookie for /admin/api/* but /recognize_tarot is a public API protected by Key. + // We should add the key to the header. + + const config = { + headers: { + 'X-API-Key': '123quant-speed' // Hardcoded as per fastAPI_tarot.py VALID_API_KEY + }, + timeout: 120000 // 2分钟超时,大模型响应较慢 + }; + + const res = await axios.post('/recognize_tarot', formData, config); + tarotResult.value = res.data; + + } catch (e) { + console.error(e); + let msg = e.response?.data?.detail || e.message || '识别请求失败'; + + // 针对 504 Gateway Timeout 或 请求超时做特殊提示 + if (e.response && e.response.status === 504) { + msg = '请求超时 (504):大模型处理时间较长。后台可能仍在运行,请稍后在“识别记录”中刷新查看结果。'; + } else if (e.code === 'ECONNABORTED') { + msg = '请求超时:网络连接中断或服务器响应过慢。请稍后重试。'; + } + + tarotResult.value = { + status: 'failed', + message: msg + }; + } finally { + isRecognizing.value = false; + } + }; + // --- Navigation & Helpers --- const switchTab = (tab) => { const prevTab = currentTab.value; @@ -1236,6 +1485,7 @@ const getPageTitle = (tab) => { const map = { 'dashboard': '数据看板', + 'tarot': '塔罗牌识别', 'history': '识别记录', 'files': '文件资源管理', 'prompts': '提示词工程', @@ -1248,6 +1498,7 @@ const getPageSubtitle = (tab) => { const map = { 'dashboard': '系统运行状态与核心指标概览', + 'tarot': 'SAM3 + Qwen-VL 联合识别与分割', 'history': '所有视觉识别任务的历史流水', 'files': '查看和管理生成的图像及JSON结果', 'prompts': '调整各个识别场景的 System Prompt', @@ -1460,7 +1711,10 @@ selectedTimeRange, selectedType, barChartRef, pieChartRef, promptPieChartRef, promptBarChartRef, wordCloudRef, formatBytes, gpuStatus, - gpuUtilChartRef, gpuTempChartRef + gpuUtilChartRef, gpuTempChartRef, + // Tarot + tarotFile, tarotImageUrl, tarotExpectedCount, tarotPreview, tarotResult, isRecognizing, + handleTarotFileChange, handleUrlInput, clearTarotInput, recognizeTarot }; } }).mount('#app'); diff --git a/static/results/1771396461_4ac00fc4/raw_for_spread.jpg b/static/results/1771396461_4ac00fc4/raw_for_spread.jpg new file mode 100644 index 0000000..ad94cbb Binary files /dev/null and b/static/results/1771396461_4ac00fc4/raw_for_spread.jpg differ diff --git a/static/results/1771396461_4ac00fc4/rotated_tarot_14dba50a95684983b4b77fa87b52d9f6_0.png b/static/results/1771396461_4ac00fc4/rotated_tarot_14dba50a95684983b4b77fa87b52d9f6_0.png new file mode 100644 index 0000000..b2c1476 Binary files /dev/null and b/static/results/1771396461_4ac00fc4/rotated_tarot_14dba50a95684983b4b77fa87b52d9f6_0.png differ diff --git a/static/results/1771396461_4ac00fc4/rotated_tarot_1d955ee085d64645b0f7bb7f6398e991_1.png b/static/results/1771396461_4ac00fc4/rotated_tarot_1d955ee085d64645b0f7bb7f6398e991_1.png new file mode 100644 index 0000000..b3b6a3c Binary files /dev/null and b/static/results/1771396461_4ac00fc4/rotated_tarot_1d955ee085d64645b0f7bb7f6398e991_1.png differ diff --git a/static/results/1771396461_4ac00fc4/rotated_tarot_7e957d28b88a4f61a5761463b992ddc5_2.png b/static/results/1771396461_4ac00fc4/rotated_tarot_7e957d28b88a4f61a5761463b992ddc5_2.png new file mode 100644 index 0000000..ff4ba4a Binary files /dev/null and b/static/results/1771396461_4ac00fc4/rotated_tarot_7e957d28b88a4f61a5761463b992ddc5_2.png differ diff --git a/static/results/1771396461_4ac00fc4/seg_f85c26fe73cf47c79d308c11f3ef3f0c.jpg b/static/results/1771396461_4ac00fc4/seg_f85c26fe73cf47c79d308c11f3ef3f0c.jpg new file mode 100644 index 0000000..dfec189 Binary files /dev/null and b/static/results/1771396461_4ac00fc4/seg_f85c26fe73cf47c79d308c11f3ef3f0c.jpg differ diff --git a/static/results/1771396461_4ac00fc4/tarot_14dba50a95684983b4b77fa87b52d9f6_0.png b/static/results/1771396461_4ac00fc4/tarot_14dba50a95684983b4b77fa87b52d9f6_0.png new file mode 100644 index 0000000..bf9876c Binary files /dev/null and b/static/results/1771396461_4ac00fc4/tarot_14dba50a95684983b4b77fa87b52d9f6_0.png differ diff --git a/static/results/1771396461_4ac00fc4/tarot_1d955ee085d64645b0f7bb7f6398e991_1.png b/static/results/1771396461_4ac00fc4/tarot_1d955ee085d64645b0f7bb7f6398e991_1.png new file mode 100644 index 0000000..7468f50 Binary files /dev/null and b/static/results/1771396461_4ac00fc4/tarot_1d955ee085d64645b0f7bb7f6398e991_1.png differ diff --git a/static/results/1771396461_4ac00fc4/tarot_7e957d28b88a4f61a5761463b992ddc5_2.png b/static/results/1771396461_4ac00fc4/tarot_7e957d28b88a4f61a5761463b992ddc5_2.png new file mode 100644 index 0000000..18d1bf2 Binary files /dev/null and b/static/results/1771396461_4ac00fc4/tarot_7e957d28b88a4f61a5761463b992ddc5_2.png differ diff --git a/static/results/1771396501_cd6d8769/raw_for_spread.jpg b/static/results/1771396501_cd6d8769/raw_for_spread.jpg new file mode 100644 index 0000000..ad94cbb Binary files /dev/null and b/static/results/1771396501_cd6d8769/raw_for_spread.jpg differ diff --git a/static/results/1771396501_cd6d8769/rotated_tarot_20fc82dab3de4b409d4417fa03b037cb_2.png b/static/results/1771396501_cd6d8769/rotated_tarot_20fc82dab3de4b409d4417fa03b037cb_2.png new file mode 100644 index 0000000..ff4ba4a Binary files /dev/null and b/static/results/1771396501_cd6d8769/rotated_tarot_20fc82dab3de4b409d4417fa03b037cb_2.png differ diff --git a/static/results/1771396501_cd6d8769/rotated_tarot_3d3da6c086a940e9aaf81ff1dd12fe2c_1.png b/static/results/1771396501_cd6d8769/rotated_tarot_3d3da6c086a940e9aaf81ff1dd12fe2c_1.png new file mode 100644 index 0000000..b3b6a3c Binary files /dev/null and b/static/results/1771396501_cd6d8769/rotated_tarot_3d3da6c086a940e9aaf81ff1dd12fe2c_1.png differ diff --git a/static/results/1771396501_cd6d8769/rotated_tarot_6e06a72206984022b230ae903728d01c_0.png b/static/results/1771396501_cd6d8769/rotated_tarot_6e06a72206984022b230ae903728d01c_0.png new file mode 100644 index 0000000..b2c1476 Binary files /dev/null and b/static/results/1771396501_cd6d8769/rotated_tarot_6e06a72206984022b230ae903728d01c_0.png differ diff --git a/static/results/1771396501_cd6d8769/seg_f8d9ba7c28fc403cbce75516ba2cd3c4.jpg b/static/results/1771396501_cd6d8769/seg_f8d9ba7c28fc403cbce75516ba2cd3c4.jpg new file mode 100644 index 0000000..dfec189 Binary files /dev/null and b/static/results/1771396501_cd6d8769/seg_f8d9ba7c28fc403cbce75516ba2cd3c4.jpg differ diff --git a/static/results/1771396501_cd6d8769/tarot_20fc82dab3de4b409d4417fa03b037cb_2.png b/static/results/1771396501_cd6d8769/tarot_20fc82dab3de4b409d4417fa03b037cb_2.png new file mode 100644 index 0000000..18d1bf2 Binary files /dev/null and b/static/results/1771396501_cd6d8769/tarot_20fc82dab3de4b409d4417fa03b037cb_2.png differ diff --git a/static/results/1771396501_cd6d8769/tarot_3d3da6c086a940e9aaf81ff1dd12fe2c_1.png b/static/results/1771396501_cd6d8769/tarot_3d3da6c086a940e9aaf81ff1dd12fe2c_1.png new file mode 100644 index 0000000..7468f50 Binary files /dev/null and b/static/results/1771396501_cd6d8769/tarot_3d3da6c086a940e9aaf81ff1dd12fe2c_1.png differ diff --git a/static/results/1771396501_cd6d8769/tarot_6e06a72206984022b230ae903728d01c_0.png b/static/results/1771396501_cd6d8769/tarot_6e06a72206984022b230ae903728d01c_0.png new file mode 100644 index 0000000..bf9876c Binary files /dev/null and b/static/results/1771396501_cd6d8769/tarot_6e06a72206984022b230ae903728d01c_0.png differ diff --git a/static/results/1771396577_a0d559ee/raw_for_spread.jpg b/static/results/1771396577_a0d559ee/raw_for_spread.jpg new file mode 100644 index 0000000..ad94cbb Binary files /dev/null and b/static/results/1771396577_a0d559ee/raw_for_spread.jpg differ diff --git a/static/results/1771396577_a0d559ee/rotated_tarot_016fa23a156a4e5f8e40ee600747c870_0.png b/static/results/1771396577_a0d559ee/rotated_tarot_016fa23a156a4e5f8e40ee600747c870_0.png new file mode 100644 index 0000000..b2c1476 Binary files /dev/null and b/static/results/1771396577_a0d559ee/rotated_tarot_016fa23a156a4e5f8e40ee600747c870_0.png differ diff --git a/static/results/1771396577_a0d559ee/rotated_tarot_11e62b79629a429687f8cba2a748b8dd_1.png b/static/results/1771396577_a0d559ee/rotated_tarot_11e62b79629a429687f8cba2a748b8dd_1.png new file mode 100644 index 0000000..b3b6a3c Binary files /dev/null and b/static/results/1771396577_a0d559ee/rotated_tarot_11e62b79629a429687f8cba2a748b8dd_1.png differ diff --git a/static/results/1771396577_a0d559ee/rotated_tarot_1364677e04824df29efd023fb94f1afe_2.png b/static/results/1771396577_a0d559ee/rotated_tarot_1364677e04824df29efd023fb94f1afe_2.png new file mode 100644 index 0000000..ff4ba4a Binary files /dev/null and b/static/results/1771396577_a0d559ee/rotated_tarot_1364677e04824df29efd023fb94f1afe_2.png differ diff --git a/static/results/1771396577_a0d559ee/seg_976a193b5025413fbc7d6064d9de0680.jpg b/static/results/1771396577_a0d559ee/seg_976a193b5025413fbc7d6064d9de0680.jpg new file mode 100644 index 0000000..dfec189 Binary files /dev/null and b/static/results/1771396577_a0d559ee/seg_976a193b5025413fbc7d6064d9de0680.jpg differ diff --git a/static/results/1771396577_a0d559ee/tarot_016fa23a156a4e5f8e40ee600747c870_0.png b/static/results/1771396577_a0d559ee/tarot_016fa23a156a4e5f8e40ee600747c870_0.png new file mode 100644 index 0000000..bf9876c Binary files /dev/null and b/static/results/1771396577_a0d559ee/tarot_016fa23a156a4e5f8e40ee600747c870_0.png differ diff --git a/static/results/1771396577_a0d559ee/tarot_11e62b79629a429687f8cba2a748b8dd_1.png b/static/results/1771396577_a0d559ee/tarot_11e62b79629a429687f8cba2a748b8dd_1.png new file mode 100644 index 0000000..7468f50 Binary files /dev/null and b/static/results/1771396577_a0d559ee/tarot_11e62b79629a429687f8cba2a748b8dd_1.png differ diff --git a/static/results/1771396577_a0d559ee/tarot_1364677e04824df29efd023fb94f1afe_2.png b/static/results/1771396577_a0d559ee/tarot_1364677e04824df29efd023fb94f1afe_2.png new file mode 100644 index 0000000..18d1bf2 Binary files /dev/null and b/static/results/1771396577_a0d559ee/tarot_1364677e04824df29efd023fb94f1afe_2.png differ