admin update
This commit is contained in:
158
fastAPI_tarot.py
158
fastAPI_tarot.py
@@ -266,7 +266,7 @@ def is_english(text: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def append_to_history(req_type: str, prompt: str, status: str, result_path: str = None, details: str = ""):
|
def append_to_history(req_type: str, prompt: str, status: str, result_path: str = None, details: str = "", final_prompt: str = None, duration: float = 0.0):
|
||||||
"""
|
"""
|
||||||
记录请求历史到 history.json
|
记录请求历史到 history.json
|
||||||
"""
|
"""
|
||||||
@@ -274,9 +274,11 @@ def append_to_history(req_type: str, prompt: str, status: str, result_path: str
|
|||||||
"timestamp": time.time(),
|
"timestamp": time.time(),
|
||||||
"type": req_type,
|
"type": req_type,
|
||||||
"prompt": prompt,
|
"prompt": prompt,
|
||||||
|
"final_prompt": final_prompt,
|
||||||
"status": status,
|
"status": status,
|
||||||
"result_path": result_path,
|
"result_path": result_path,
|
||||||
"details": details
|
"details": details,
|
||||||
|
"duration": duration
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
with open(HISTORY_FILE, "a", encoding="utf-8") as f:
|
with open(HISTORY_FILE, "a", encoding="utf-8") as f:
|
||||||
@@ -371,7 +373,7 @@ def load_image_from_url(url: str) -> Image.Image:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=400, detail=f"无法下载图片: {str(e)}")
|
raise HTTPException(status_code=400, detail=f"无法下载图片: {str(e)}")
|
||||||
|
|
||||||
def crop_and_save_objects(image: Image.Image, masks, boxes, output_dir: str = RESULT_IMAGE_DIR, is_tarot: bool = True, cutout: bool = False) -> list[dict]:
|
def crop_and_save_objects(image: Image.Image, masks, boxes, output_dir: str = RESULT_IMAGE_DIR, is_tarot: bool = True, cutout: bool = False, perspective_correction: bool = False) -> list[dict]:
|
||||||
"""
|
"""
|
||||||
根据 mask 和 box 进行处理并保存独立的对象图片
|
根据 mask 和 box 进行处理并保存独立的对象图片
|
||||||
|
|
||||||
@@ -382,6 +384,7 @@ def crop_and_save_objects(image: Image.Image, masks, boxes, output_dir: str = RE
|
|||||||
- output_dir: 输出目录
|
- output_dir: 输出目录
|
||||||
- is_tarot: 是否为塔罗牌模式 (会影响文件名前缀和旋转逻辑)
|
- is_tarot: 是否为塔罗牌模式 (会影响文件名前缀和旋转逻辑)
|
||||||
- cutout: 如果为 True,则进行轮廓抠图(透明背景);否则进行透视矫正(主要用于卡片)
|
- cutout: 如果为 True,则进行轮廓抠图(透明背景);否则进行透视矫正(主要用于卡片)
|
||||||
|
- perspective_correction: 是否进行梯度透视矫正
|
||||||
|
|
||||||
返回:
|
返回:
|
||||||
- 保存的对象信息列表
|
- 保存的对象信息列表
|
||||||
@@ -409,8 +412,8 @@ def crop_and_save_objects(image: Image.Image, masks, boxes, output_dir: str = RE
|
|||||||
else:
|
else:
|
||||||
mask_uint8 = (mask_np > 0.5).astype(np.uint8) * 255
|
mask_uint8 = (mask_np > 0.5).astype(np.uint8) * 255
|
||||||
|
|
||||||
|
# --- 准备基础图像 ---
|
||||||
if cutout:
|
if cutout:
|
||||||
# --- 轮廓抠图模式 (透明背景) ---
|
|
||||||
# 1. 准备 RGBA 原图
|
# 1. 准备 RGBA 原图
|
||||||
if image.mode != "RGBA":
|
if image.mode != "RGBA":
|
||||||
img_rgba = image.convert("RGBA")
|
img_rgba = image.convert("RGBA")
|
||||||
@@ -421,40 +424,26 @@ def crop_and_save_objects(image: Image.Image, masks, boxes, output_dir: str = RE
|
|||||||
mask_img = Image.fromarray(mask_uint8, mode='L')
|
mask_img = Image.fromarray(mask_uint8, mode='L')
|
||||||
|
|
||||||
# 3. 将 Mask 应用到 Alpha 通道
|
# 3. 将 Mask 应用到 Alpha 通道
|
||||||
cutout_img = Image.new("RGBA", img_rgba.size, (0, 0, 0, 0))
|
base_img_pil = Image.new("RGBA", img_rgba.size, (0, 0, 0, 0))
|
||||||
cutout_img.paste(image.convert("RGB"), (0, 0), mask=mask_img)
|
base_img_pil.paste(image.convert("RGB"), (0, 0), mask=mask_img)
|
||||||
|
|
||||||
# 4. Crop to Box
|
# Convert to numpy for potential warping
|
||||||
x1, y1, x2, y2 = map(int, box_np)
|
base_img_arr = np.array(base_img_pil)
|
||||||
w, h = cutout_img.size
|
|
||||||
x1 = max(0, x1); y1 = max(0, y1)
|
|
||||||
x2 = min(w, x2); y2 = min(h, y2)
|
|
||||||
|
|
||||||
if x2 > x1 and y2 > y1:
|
|
||||||
final_img = cutout_img.crop((x1, y1, x2, y2))
|
|
||||||
else:
|
else:
|
||||||
final_img = cutout_img # Fallback
|
base_img_pil = image.convert("RGB")
|
||||||
|
base_img_arr = img_arr # RGB numpy array
|
||||||
|
|
||||||
# Save
|
# --- 透视矫正 vs 简单裁剪 ---
|
||||||
prefix = "cutout"
|
final_img_pil = None
|
||||||
is_rotated = False
|
is_rotated = False
|
||||||
|
note = ""
|
||||||
|
|
||||||
filename = f"{prefix}_{uuid.uuid4().hex}_{i}.png"
|
if perspective_correction:
|
||||||
save_path = os.path.join(output_dir, filename)
|
|
||||||
final_img.save(save_path)
|
|
||||||
|
|
||||||
saved_objects.append({
|
|
||||||
"filename": filename,
|
|
||||||
"is_rotated_by_algorithm": is_rotated,
|
|
||||||
"note": "Mask cutout applied. Background removed."
|
|
||||||
})
|
|
||||||
|
|
||||||
else:
|
|
||||||
# --- 透视矫正模式 (矩形矫正) ---
|
# --- 透视矫正模式 (矩形矫正) ---
|
||||||
contours, _ = cv2.findContours(mask_uint8, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
contours, _ = cv2.findContours(mask_uint8, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
if not contours:
|
|
||||||
continue
|
|
||||||
|
|
||||||
|
pts = None
|
||||||
|
if contours:
|
||||||
c = max(contours, key=cv2.contourArea)
|
c = max(contours, key=cv2.contourArea)
|
||||||
peri = cv2.arcLength(c, True)
|
peri = cv2.arcLength(c, True)
|
||||||
approx = cv2.approxPolyDP(c, 0.04 * peri, True)
|
approx = cv2.approxPolyDP(c, 0.04 * peri, True)
|
||||||
@@ -465,28 +454,53 @@ def crop_and_save_objects(image: Image.Image, masks, boxes, output_dir: str = RE
|
|||||||
rect = cv2.minAreaRect(c)
|
rect = cv2.minAreaRect(c)
|
||||||
pts = cv2.boxPoints(rect)
|
pts = cv2.boxPoints(rect)
|
||||||
|
|
||||||
warped = four_point_transform(img_arr, pts)
|
if pts is not None:
|
||||||
|
warped = four_point_transform(base_img_arr, pts)
|
||||||
|
note = "Geometric correction applied."
|
||||||
|
else:
|
||||||
|
# Fallback to simple crop if no contours found
|
||||||
|
x1, y1, x2, y2 = map(int, box_np)
|
||||||
|
# Ensure bounds
|
||||||
|
h, w = base_img_arr.shape[:2]
|
||||||
|
x1 = max(0, x1); y1 = max(0, y1)
|
||||||
|
x2 = min(w, x2); y2 = min(h, y2)
|
||||||
|
warped = base_img_arr[y1:y2, x1:x2]
|
||||||
|
note = "Correction failed, fallback to crop."
|
||||||
|
|
||||||
# Check orientation (Portrait vs Landscape)
|
# Check orientation (Portrait vs Landscape) - Only for Tarot usually
|
||||||
h, w = warped.shape[:2]
|
h, w = warped.shape[:2]
|
||||||
is_rotated = False
|
|
||||||
|
|
||||||
# 强制竖屏逻辑 (塔罗牌通常是竖屏)
|
# 强制竖屏逻辑 (塔罗牌通常是竖屏)
|
||||||
if is_tarot and w > h:
|
if is_tarot and w > h:
|
||||||
warped = cv2.rotate(warped, cv2.ROTATE_90_CLOCKWISE)
|
warped = cv2.rotate(warped, cv2.ROTATE_90_CLOCKWISE)
|
||||||
is_rotated = True
|
is_rotated = True
|
||||||
|
|
||||||
pil_warped = Image.fromarray(warped)
|
final_img_pil = Image.fromarray(warped)
|
||||||
|
|
||||||
prefix = "tarot" if is_tarot else "segment"
|
else:
|
||||||
|
# --- 简单裁剪模式 (Simple Crop) ---
|
||||||
|
x1, y1, x2, y2 = map(int, box_np)
|
||||||
|
w, h = base_img_pil.size
|
||||||
|
x1 = max(0, x1); y1 = max(0, y1)
|
||||||
|
x2 = min(w, x2); y2 = min(h, y2)
|
||||||
|
|
||||||
|
if x2 > x1 and y2 > y1:
|
||||||
|
final_img_pil = base_img_pil.crop((x1, y1, x2, y2))
|
||||||
|
else:
|
||||||
|
final_img_pil = base_img_pil # Fallback
|
||||||
|
|
||||||
|
note = "Simple crop applied."
|
||||||
|
|
||||||
|
# --- 保存图片 ---
|
||||||
|
prefix = "cutout" if cutout else ("tarot" if is_tarot else "segment")
|
||||||
filename = f"{prefix}_{uuid.uuid4().hex}_{i}.png"
|
filename = f"{prefix}_{uuid.uuid4().hex}_{i}.png"
|
||||||
save_path = os.path.join(output_dir, filename)
|
save_path = os.path.join(output_dir, filename)
|
||||||
pil_warped.save(save_path)
|
final_img_pil.save(save_path)
|
||||||
|
|
||||||
saved_objects.append({
|
saved_objects.append({
|
||||||
"filename": filename,
|
"filename": filename,
|
||||||
"is_rotated_by_algorithm": is_rotated,
|
"is_rotated_by_algorithm": is_rotated,
|
||||||
"note": "Geometric correction applied."
|
"note": note
|
||||||
})
|
})
|
||||||
|
|
||||||
return saved_objects
|
return saved_objects
|
||||||
@@ -660,6 +674,7 @@ async def segment(
|
|||||||
image_url: Optional[str] = Form(None, description="URL of the image"),
|
image_url: Optional[str] = Form(None, description="URL of the image"),
|
||||||
save_segment_images: bool = Form(False, description="Whether to save and return individual segmented objects"),
|
save_segment_images: bool = Form(False, description="Whether to save and return individual segmented objects"),
|
||||||
cutout: bool = Form(False, description="If True, returns transparent background PNGs; otherwise returns original crops"),
|
cutout: bool = Form(False, description="If True, returns transparent background PNGs; otherwise returns original crops"),
|
||||||
|
perspective_correction: bool = Form(False, description="If True, applies perspective correction (warping) to the segmented object."),
|
||||||
highlight: bool = Form(False, description="If True, darkens the background to highlight the subject (周边变黑放大)."),
|
highlight: bool = Form(False, description="If True, darkens the background to highlight the subject (周边变黑放大)."),
|
||||||
confidence: float = Form(0.7, description="Confidence threshold (0.0-1.0). Default is 0.7.")
|
confidence: float = Form(0.7, description="Confidence threshold (0.0-1.0). Default is 0.7.")
|
||||||
|
|
||||||
@@ -671,11 +686,13 @@ async def segment(
|
|||||||
- 支持自动将中文 Prompt 翻译为英文
|
- 支持自动将中文 Prompt 翻译为英文
|
||||||
- 支持周边变黑放大效果 (Highlight Mode)
|
- 支持周边变黑放大效果 (Highlight Mode)
|
||||||
- 支持手动设置置信度 (Confidence Threshold)
|
- 支持手动设置置信度 (Confidence Threshold)
|
||||||
|
- 支持透视矫正 (Perspective Correction)
|
||||||
|
|
||||||
"""
|
"""
|
||||||
if not file and not image_url:
|
if not file and not image_url:
|
||||||
raise HTTPException(status_code=400, detail="必须提供 file (图片文件) 或 image_url (图片链接)")
|
raise HTTPException(status_code=400, detail="必须提供 file (图片文件) 或 image_url (图片链接)")
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
# 1. Prompt 处理
|
# 1. Prompt 处理
|
||||||
final_prompt = prompt
|
final_prompt = prompt
|
||||||
if not is_english(prompt):
|
if not is_english(prompt):
|
||||||
@@ -695,7 +712,8 @@ async def segment(
|
|||||||
elif image_url:
|
elif image_url:
|
||||||
image = load_image_from_url(image_url)
|
image = load_image_from_url(image_url)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
append_to_history("general", prompt, "failed", details=f"Image Load Error: {str(e)}")
|
duration = time.time() - start_time
|
||||||
|
append_to_history("general", prompt, "failed", details=f"Image Load Error: {str(e)}", final_prompt=final_prompt, duration=duration)
|
||||||
raise HTTPException(status_code=400, detail=f"图片解析失败: {str(e)}")
|
raise HTTPException(status_code=400, detail=f"图片解析失败: {str(e)}")
|
||||||
|
|
||||||
processor = request.app.state.processor
|
processor = request.app.state.processor
|
||||||
@@ -725,7 +743,8 @@ async def segment(
|
|||||||
processor.confidence_threshold = original_confidence
|
processor.confidence_threshold = original_confidence
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
append_to_history("general", prompt, "failed", details=f"Inference Error: {str(e)}")
|
duration = time.time() - start_time
|
||||||
|
append_to_history("general", prompt, "failed", details=f"Inference Error: {str(e)}", final_prompt=final_prompt, duration=duration)
|
||||||
raise HTTPException(status_code=500, detail=f"模型推理错误: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"模型推理错误: {str(e)}")
|
||||||
|
|
||||||
# 4. 结果可视化与保存
|
# 4. 结果可视化与保存
|
||||||
@@ -738,7 +757,8 @@ async def segment(
|
|||||||
else:
|
else:
|
||||||
filename = generate_and_save_result(image, inference_state)
|
filename = generate_and_save_result(image, inference_state)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
append_to_history("general", prompt, "failed", details=f"Save Error: {str(e)}")
|
duration = time.time() - start_time
|
||||||
|
append_to_history("general", prompt, "failed", details=f"Save Error: {str(e)}", final_prompt=final_prompt, duration=duration)
|
||||||
raise HTTPException(status_code=500, detail=f"绘图保存错误: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"绘图保存错误: {str(e)}")
|
||||||
|
|
||||||
file_url = request.url_for("static", path=f"results/{filename}")
|
file_url = request.url_for("static", path=f"results/{filename}")
|
||||||
@@ -757,7 +777,8 @@ async def segment(
|
|||||||
boxes,
|
boxes,
|
||||||
output_dir=output_dir,
|
output_dir=output_dir,
|
||||||
is_tarot=False,
|
is_tarot=False,
|
||||||
cutout=cutout
|
cutout=cutout,
|
||||||
|
perspective_correction=perspective_correction
|
||||||
)
|
)
|
||||||
|
|
||||||
for obj in saved_objects:
|
for obj in saved_objects:
|
||||||
@@ -771,7 +792,8 @@ async def segment(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error saving segments: {e}")
|
print(f"Error saving segments: {e}")
|
||||||
# Don't fail the whole request just for this part, but log it? Or fail? Usually fail.
|
# Don't fail the whole request just for this part, but log it? Or fail? Usually fail.
|
||||||
append_to_history("general", prompt, "partial_success", result_path=f"results/{filename}", details="Segments save failed")
|
duration = time.time() - start_time
|
||||||
|
append_to_history("general", prompt, "partial_success", result_path=f"results/{filename}", details="Segments save failed", final_prompt=final_prompt, duration=duration)
|
||||||
raise HTTPException(status_code=500, detail=f"保存分割图片失败: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"保存分割图片失败: {str(e)}")
|
||||||
|
|
||||||
response_content = {
|
response_content = {
|
||||||
@@ -784,7 +806,8 @@ async def segment(
|
|||||||
if save_segment_images:
|
if save_segment_images:
|
||||||
response_content["segmented_images"] = saved_segments_info
|
response_content["segmented_images"] = saved_segments_info
|
||||||
|
|
||||||
append_to_history("general", prompt, "success", result_path=f"results/{filename}", details=f"Detected: {len(masks)}")
|
duration = time.time() - start_time
|
||||||
|
append_to_history("general", prompt, "success", result_path=f"results/{filename}", details=f"Detected: {len(masks)}", final_prompt=final_prompt, duration=duration)
|
||||||
return JSONResponse(content=response_content)
|
return JSONResponse(content=response_content)
|
||||||
|
|
||||||
# ------------------------------------------
|
# ------------------------------------------
|
||||||
@@ -808,13 +831,15 @@ async def segment_tarot(
|
|||||||
if not file and not image_url:
|
if not file and not image_url:
|
||||||
raise HTTPException(status_code=400, detail="必须提供 file (图片文件) 或 image_url (图片链接)")
|
raise HTTPException(status_code=400, detail="必须提供 file (图片文件) 或 image_url (图片链接)")
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
try:
|
try:
|
||||||
if file:
|
if file:
|
||||||
image = Image.open(file.file).convert("RGB")
|
image = Image.open(file.file).convert("RGB")
|
||||||
elif image_url:
|
elif image_url:
|
||||||
image = load_image_from_url(image_url)
|
image = load_image_from_url(image_url)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
append_to_history("tarot", f"expected: {expected_count}", "failed", details=f"Image Load Error: {str(e)}")
|
duration = time.time() - start_time
|
||||||
|
append_to_history("tarot", f"expected: {expected_count}", "failed", details=f"Image Load Error: {str(e)}", duration=duration)
|
||||||
raise HTTPException(status_code=400, detail=f"图片解析失败: {str(e)}")
|
raise HTTPException(status_code=400, detail=f"图片解析失败: {str(e)}")
|
||||||
|
|
||||||
processor = request.app.state.processor
|
processor = request.app.state.processor
|
||||||
@@ -825,7 +850,8 @@ async def segment_tarot(
|
|||||||
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"]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
append_to_history("tarot", f"expected: {expected_count}", "failed", details=f"Inference Error: {str(e)}")
|
duration = time.time() - start_time
|
||||||
|
append_to_history("tarot", f"expected: {expected_count}", "failed", details=f"Inference Error: {str(e)}", duration=duration)
|
||||||
raise HTTPException(status_code=500, detail=f"模型推理错误: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"模型推理错误: {str(e)}")
|
||||||
|
|
||||||
# 核心逻辑:判断数量
|
# 核心逻辑:判断数量
|
||||||
@@ -844,7 +870,8 @@ async def segment_tarot(
|
|||||||
except:
|
except:
|
||||||
file_url = None
|
file_url = None
|
||||||
|
|
||||||
append_to_history("tarot", f"expected: {expected_count}", "failed", result_path=f"results/{request_id}/{filename}" if file_url else None, details=f"Detected {detected_count} cards, expected {expected_count}")
|
duration = time.time() - start_time
|
||||||
|
append_to_history("tarot", f"expected: {expected_count}", "failed", result_path=f"results/{request_id}/{filename}" if file_url else None, details=f"Detected {detected_count} cards, expected {expected_count}", duration=duration)
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
content={
|
content={
|
||||||
@@ -857,9 +884,10 @@ async def segment_tarot(
|
|||||||
|
|
||||||
# 数量正确,执行抠图
|
# 数量正确,执行抠图
|
||||||
try:
|
try:
|
||||||
saved_objects = crop_and_save_objects(image, masks, boxes, output_dir=output_dir)
|
saved_objects = crop_and_save_objects(image, masks, boxes, output_dir=output_dir, is_tarot=True, perspective_correction=True)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
append_to_history("tarot", f"expected: {expected_count}", "failed", details=f"Crop Error: {str(e)}")
|
duration = time.time() - start_time
|
||||||
|
append_to_history("tarot", 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)}")
|
||||||
|
|
||||||
# 生成 URL 列表和元数据
|
# 生成 URL 列表和元数据
|
||||||
@@ -881,7 +909,8 @@ async def segment_tarot(
|
|||||||
except:
|
except:
|
||||||
main_file_url = None
|
main_file_url = None
|
||||||
|
|
||||||
append_to_history("tarot", f"expected: {expected_count}", "success", result_path=f"results/{request_id}/{main_filename}" if main_file_url else None, details=f"Successfully segmented {expected_count} cards")
|
duration = time.time() - start_time
|
||||||
|
append_to_history("tarot", f"expected: {expected_count}", "success", result_path=f"results/{request_id}/{main_filename}" if main_file_url else None, details=f"Successfully segmented {expected_count} cards", duration=duration)
|
||||||
return JSONResponse(content={
|
return JSONResponse(content={
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"message": f"成功识别并分割 {expected_count} 张塔罗牌 (已执行透视矫正)",
|
"message": f"成功识别并分割 {expected_count} 张塔罗牌 (已执行透视矫正)",
|
||||||
@@ -907,13 +936,15 @@ async def recognize_tarot(
|
|||||||
if not file and not image_url:
|
if not file and not image_url:
|
||||||
raise HTTPException(status_code=400, detail="必须提供 file (图片文件) 或 image_url (图片链接)")
|
raise HTTPException(status_code=400, detail="必须提供 file (图片文件) 或 image_url (图片链接)")
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
try:
|
try:
|
||||||
if file:
|
if file:
|
||||||
image = Image.open(file.file).convert("RGB")
|
image = Image.open(file.file).convert("RGB")
|
||||||
elif image_url:
|
elif image_url:
|
||||||
image = load_image_from_url(image_url)
|
image = load_image_from_url(image_url)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
append_to_history("tarot-recognize", f"expected: {expected_count}", "failed", details=f"Image Load Error: {str(e)}")
|
duration = time.time() - start_time
|
||||||
|
append_to_history("tarot-recognize", f"expected: {expected_count}", "failed", details=f"Image Load Error: {str(e)}", duration=duration)
|
||||||
raise HTTPException(status_code=400, detail=f"图片解析失败: {str(e)}")
|
raise HTTPException(status_code=400, detail=f"图片解析失败: {str(e)}")
|
||||||
|
|
||||||
processor = request.app.state.processor
|
processor = request.app.state.processor
|
||||||
@@ -923,7 +954,8 @@ async def recognize_tarot(
|
|||||||
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"]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
append_to_history("tarot-recognize", f"expected: {expected_count}", "failed", details=f"Inference Error: {str(e)}")
|
duration = time.time() - start_time
|
||||||
|
append_to_history("tarot-recognize", f"expected: {expected_count}", "failed", details=f"Inference Error: {str(e)}", duration=duration)
|
||||||
raise HTTPException(status_code=500, detail=f"模型推理错误: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"模型推理错误: {str(e)}")
|
||||||
|
|
||||||
detected_count = len(masks)
|
detected_count = len(masks)
|
||||||
@@ -951,7 +983,8 @@ async def recognize_tarot(
|
|||||||
spread_info = recognize_spread_with_qwen(temp_raw_path)
|
spread_info = recognize_spread_with_qwen(temp_raw_path)
|
||||||
|
|
||||||
if detected_count != expected_count:
|
if detected_count != expected_count:
|
||||||
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 = 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(
|
return JSONResponse(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
content={
|
content={
|
||||||
@@ -965,9 +998,10 @@ async def recognize_tarot(
|
|||||||
|
|
||||||
# 数量正确,执行抠图 + 矫正
|
# 数量正确,执行抠图 + 矫正
|
||||||
try:
|
try:
|
||||||
saved_objects = crop_and_save_objects(image, masks, boxes, output_dir=output_dir)
|
saved_objects = crop_and_save_objects(image, masks, boxes, output_dir=output_dir, is_tarot=True, perspective_correction=True)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
append_to_history("tarot-recognize", f"expected: {expected_count}", "failed", details=f"Crop Error: {str(e)}")
|
duration = time.time() - start_time
|
||||||
|
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)}")
|
||||||
|
|
||||||
# 遍历每张卡片进行识别
|
# 遍历每张卡片进行识别
|
||||||
@@ -988,7 +1022,8 @@ async def recognize_tarot(
|
|||||||
"note": obj["note"]
|
"note": obj["note"]
|
||||||
})
|
})
|
||||||
|
|
||||||
append_to_history("tarot-recognize", f"expected: {expected_count}", "success", result_path=f"results/{request_id}/{main_filename}" if main_file_url else None, details=f"Spread: {spread_info.get('spread_name', 'Unknown')}")
|
duration = time.time() - start_time
|
||||||
|
append_to_history("tarot-recognize", f"expected: {expected_count}", "success", result_path=f"results/{request_id}/{main_filename}" if main_file_url else None, details=f"Spread: {spread_info.get('spread_name', 'Unknown')}", duration=duration)
|
||||||
return JSONResponse(content={
|
return JSONResponse(content={
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"message": f"成功识别并分割 {expected_count} 张塔罗牌 (含Qwen识别结果)",
|
"message": f"成功识别并分割 {expected_count} 张塔罗牌 (含Qwen识别结果)",
|
||||||
@@ -1019,6 +1054,7 @@ async def segment_face(
|
|||||||
if not file and not image_url:
|
if not file and not image_url:
|
||||||
raise HTTPException(status_code=400, detail="必须提供 file (图片文件) 或 image_url (图片链接)")
|
raise HTTPException(status_code=400, detail="必须提供 file (图片文件) 或 image_url (图片链接)")
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
# Prompt 翻译/优化
|
# Prompt 翻译/优化
|
||||||
final_prompt = prompt
|
final_prompt = prompt
|
||||||
if not is_english(prompt):
|
if not is_english(prompt):
|
||||||
@@ -1038,7 +1074,8 @@ async def segment_face(
|
|||||||
elif image_url:
|
elif image_url:
|
||||||
image = load_image_from_url(image_url)
|
image = load_image_from_url(image_url)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
append_to_history("face", prompt, "failed", details=f"Image Load Error: {str(e)}")
|
duration = time.time() - start_time
|
||||||
|
append_to_history("face", prompt, "failed", details=f"Image Load Error: {str(e)}", final_prompt=final_prompt, duration=duration)
|
||||||
raise HTTPException(status_code=400, detail=f"图片解析失败: {str(e)}")
|
raise HTTPException(status_code=400, detail=f"图片解析失败: {str(e)}")
|
||||||
|
|
||||||
processor = request.app.state.processor
|
processor = request.app.state.processor
|
||||||
@@ -1056,7 +1093,8 @@ async def segment_face(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
import traceback
|
import traceback
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
append_to_history("face", prompt, "failed", details=f"Process Error: {str(e)}")
|
duration = time.time() - start_time
|
||||||
|
append_to_history("face", prompt, "failed", details=f"Process Error: {str(e)}", final_prompt=final_prompt, duration=duration)
|
||||||
raise HTTPException(status_code=500, detail=f"处理失败: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"处理失败: {str(e)}")
|
||||||
|
|
||||||
# 补全 URL
|
# 补全 URL
|
||||||
@@ -1069,7 +1107,8 @@ async def segment_face(
|
|||||||
relative_path = item.pop("relative_path")
|
relative_path = item.pop("relative_path")
|
||||||
item["url"] = str(request.url_for("static", path=relative_path))
|
item["url"] = str(request.url_for("static", path=relative_path))
|
||||||
|
|
||||||
append_to_history("face", prompt, result["status"], details=f"Results: {len(result.get('results', []))}")
|
duration = time.time() - start_time
|
||||||
|
append_to_history("face", prompt, result["status"], details=f"Results: {len(result.get('results', []))}", final_prompt=final_prompt, duration=duration)
|
||||||
return JSONResponse(content=result)
|
return JSONResponse(content=result)
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
@@ -1168,6 +1207,9 @@ async def list_files(path: str = ""):
|
|||||||
# path is relative to results/
|
# path is relative to results/
|
||||||
# so url is /static/results/path/name
|
# so url is /static/results/path/name
|
||||||
rel_path = os.path.join("results", path, entry.name)
|
rel_path = os.path.join("results", path, entry.name)
|
||||||
|
# Ensure forward slashes for URL
|
||||||
|
if os.sep != "/":
|
||||||
|
rel_path = rel_path.replace(os.sep, "/")
|
||||||
item["url"] = f"/static/{rel_path}"
|
item["url"] = f"/static/{rel_path}"
|
||||||
|
|
||||||
items.append(item)
|
items.append(item)
|
||||||
|
|||||||
0
history.json
Normal file
0
history.json
Normal file
@@ -65,58 +65,91 @@
|
|||||||
<main class="flex-1 overflow-y-auto bg-gray-50 p-8">
|
<main class="flex-1 overflow-y-auto bg-gray-50 p-8">
|
||||||
<!-- 识别记录 Dashboard -->
|
<!-- 识别记录 Dashboard -->
|
||||||
<div v-if="currentTab === 'dashboard'">
|
<div v-if="currentTab === 'dashboard'">
|
||||||
<h2 class="text-2xl font-bold mb-6 text-gray-800 border-b pb-2">最近识别记录</h2>
|
<h2 class="text-2xl font-bold mb-6 text-gray-800 border-b pb-2 flex items-center justify-between">
|
||||||
|
<span>最近识别记录</span>
|
||||||
|
<button @click="fetchHistory" class="bg-blue-500 hover:bg-blue-600 text-white py-1 px-3 rounded text-sm transition shadow-sm">
|
||||||
|
<i class="fas fa-sync-alt mr-1"></i> 刷新
|
||||||
|
</button>
|
||||||
|
</h2>
|
||||||
|
|
||||||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
<div class="bg-white rounded-lg shadow overflow-hidden border border-gray-100">
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="min-w-full leading-normal">
|
<table class="min-w-full leading-normal">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-5 py-3 border-b-2 border-gray-200 bg-gray-100 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">时间</th>
|
<th class="px-5 py-3 border-b border-gray-200 bg-gray-50 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider w-32">时间</th>
|
||||||
<th class="px-5 py-3 border-b-2 border-gray-200 bg-gray-100 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">类型</th>
|
<th class="px-5 py-3 border-b border-gray-200 bg-gray-50 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider w-24">类型</th>
|
||||||
<th class="px-5 py-3 border-b-2 border-gray-200 bg-gray-100 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">Prompt / 详情</th>
|
<th class="px-5 py-3 border-b border-gray-200 bg-gray-50 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Prompt / 详情</th>
|
||||||
<th class="px-5 py-3 border-b-2 border-gray-200 bg-gray-100 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">状态</th>
|
<th class="px-5 py-3 border-b border-gray-200 bg-gray-50 text-center text-xs font-semibold text-gray-500 uppercase tracking-wider w-24">耗时</th>
|
||||||
<th class="px-5 py-3 border-b-2 border-gray-200 bg-gray-100 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">操作</th>
|
<th class="px-5 py-3 border-b border-gray-200 bg-gray-50 text-center text-xs font-semibold text-gray-500 uppercase tracking-wider w-24">状态</th>
|
||||||
|
<th class="px-5 py-3 border-b border-gray-200 bg-gray-50 text-center text-xs font-semibold text-gray-500 uppercase tracking-wider w-20">查看</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="(record, index) in history" :key="index" class="hover:bg-gray-50">
|
<tr v-for="(record, index) in history" :key="index" class="hover:bg-gray-50 transition duration-150">
|
||||||
<td class="px-5 py-5 border-b border-gray-200 bg-white text-sm">
|
<td class="px-5 py-4 border-b border-gray-100 bg-white text-sm text-gray-600 whitespace-nowrap">
|
||||||
{{ formatDate(record.timestamp) }}
|
<div class="font-medium">{{ formatDate(record.timestamp).split(' ')[0] }}</div>
|
||||||
|
<div class="text-xs text-gray-400">{{ formatDate(record.timestamp).split(' ')[1] }}</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-5 py-5 border-b border-gray-200 bg-white text-sm">
|
<td class="px-5 py-4 border-b border-gray-100 bg-white text-sm">
|
||||||
<span :class="getTypeBadgeClass(record.type)" class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full">
|
<span :class="getTypeBadgeClass(record.type)" class="px-2 py-1 text-xs font-semibold rounded-md shadow-sm">
|
||||||
{{ record.type }}
|
{{ record.type }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-5 py-5 border-b border-gray-200 bg-white text-sm max-w-xs truncate" :title="record.details">
|
<td class="px-5 py-4 border-b border-gray-100 bg-white text-sm">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<!-- Prompt -->
|
||||||
|
<div v-if="record.prompt" class="font-medium text-gray-800 break-words flex items-center gap-2">
|
||||||
|
<i class="fas fa-keyboard text-gray-300 text-xs"></i>
|
||||||
|
{{ record.prompt }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Translated Prompt -->
|
||||||
|
<div v-if="record.final_prompt && record.final_prompt !== record.prompt" class="text-xs text-gray-500 flex items-center gap-2">
|
||||||
|
<i class="fas fa-language text-blue-300 text-xs"></i>
|
||||||
|
<span class="italic bg-gray-50 px-1 rounded">{{ record.final_prompt }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Details -->
|
||||||
|
<div class="text-xs text-gray-400 mt-1 flex items-center gap-2">
|
||||||
|
<i class="fas fa-info-circle text-gray-300"></i>
|
||||||
{{ record.details }}
|
{{ record.details }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-5 py-5 border-b border-gray-200 bg-white text-sm">
|
<td class="px-5 py-4 border-b border-gray-100 bg-white text-sm text-center">
|
||||||
<span :class="record.status === 'success' ? 'text-green-900 bg-green-200' : 'text-red-900 bg-red-200'" class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full">
|
<div v-if="record.duration" :class="getDurationClass(record.duration)" class="font-mono text-xs inline-block px-2 py-0.5 rounded">
|
||||||
|
{{ record.duration.toFixed(2) }}s
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-gray-300 text-xs">-</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-5 py-4 border-b border-gray-100 bg-white text-sm text-center">
|
||||||
|
<span :class="record.status === 'success' ? 'text-green-700 bg-green-100 ring-1 ring-green-200' : (record.status === 'partial_success' ? 'text-yellow-700 bg-yellow-100 ring-1 ring-yellow-200' : 'text-red-700 bg-red-100 ring-1 ring-red-200')" class="px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full">
|
||||||
|
<i :class="record.status === 'success' ? 'fas fa-check-circle' : (record.status === 'partial_success' ? 'fas fa-exclamation-circle' : 'fas fa-times-circle')" class="mr-1 mt-0.5"></i>
|
||||||
{{ record.status }}
|
{{ record.status }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-5 py-5 border-b border-gray-200 bg-white text-sm">
|
<td class="px-5 py-4 border-b border-gray-100 bg-white text-sm text-center">
|
||||||
<button v-if="record.result_path" @click="viewResult(record.result_path)" class="text-blue-600 hover:text-blue-900 mr-3">
|
<button v-if="record.result_path" @click="viewResult(record.result_path)" class="text-blue-500 hover:text-blue-700 transition transform hover:scale-110" title="查看结果">
|
||||||
查看
|
<i class="fas fa-external-link-alt"></i>
|
||||||
</button>
|
</button>
|
||||||
|
<span v-else class="text-gray-300 cursor-not-allowed">
|
||||||
|
<i class="fas fa-eye-slash"></i>
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="history.length === 0">
|
<tr v-if="history.length === 0">
|
||||||
<td colspan="5" class="px-5 py-5 border-b border-gray-200 bg-white text-sm text-center text-gray-500">
|
<td colspan="6" class="px-5 py-10 border-b border-gray-200 bg-white text-sm text-center text-gray-400">
|
||||||
|
<div class="flex flex-col items-center">
|
||||||
|
<i class="fas fa-inbox text-4xl mb-3 text-gray-200"></i>
|
||||||
暂无记录
|
暂无记录
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 flex justify-end">
|
|
||||||
<button @click="fetchHistory" class="bg-blue-500 hover:bg-blue-600 text-white py-1 px-3 rounded text-sm">
|
|
||||||
<i class="fas fa-sync-alt"></i> 刷新
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 文件管理 Files -->
|
<!-- 文件管理 Files -->
|
||||||
@@ -145,7 +178,7 @@
|
|||||||
<span class="text-xs text-gray-400">{{ file.count }} 项</span>
|
<span class="text-xs text-gray-400">{{ file.count }} 项</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- Image File -->
|
<!-- Image File -->
|
||||||
<div v-else-if="isImage(file.name)" @click="previewImage(file.path)" class="flex flex-col items-center justify-center h-32">
|
<div v-else-if="isImage(file.name)" @click="previewImage(file.url)" class="flex flex-col items-center justify-center h-32">
|
||||||
<img :src="file.url" class="h-20 w-auto object-contain mb-2 rounded" loading="lazy">
|
<img :src="file.url" class="h-20 w-auto object-contain mb-2 rounded" loading="lazy">
|
||||||
<span class="text-xs text-center break-all px-1 truncate w-full">{{ file.name }}</span>
|
<span class="text-xs text-center break-all px-1 truncate w-full">{{ file.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -456,17 +489,36 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const viewResult = (path) => {
|
const viewResult = (path) => {
|
||||||
// path like "results/..."
|
// path format: "results/subdir/file.jpg" or "results/file.jpg"
|
||||||
// We need to parse this. If it's a directory, go to files tab. If image, preview.
|
|
||||||
// For simplicity, let's assume it links to the folder in files tab
|
|
||||||
currentTab.value = 'files';
|
currentTab.value = 'files';
|
||||||
// Extract folder name from path if possible, or just go to root
|
|
||||||
const match = path.match(/results\/([^\/]+)/);
|
// Remove "results/" prefix
|
||||||
if (match) {
|
// Note: path usually comes from backend as "results/..."
|
||||||
currentPath.value = match[1];
|
let relativePath = path;
|
||||||
|
if (relativePath.startsWith('results/')) {
|
||||||
|
relativePath = relativePath.substring(8); // Remove "results/"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it looks like a file (has extension)
|
||||||
|
const isFile = /\.[a-zA-Z0-9]+$/.test(relativePath);
|
||||||
|
|
||||||
|
if (isFile) {
|
||||||
|
// It's a file
|
||||||
|
const lastSlashIndex = relativePath.lastIndexOf('/');
|
||||||
|
let dirPath = '';
|
||||||
|
|
||||||
|
if (lastSlashIndex !== -1) {
|
||||||
|
dirPath = relativePath.substring(0, lastSlashIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
currentPath.value = dirPath;
|
||||||
fetchFiles();
|
fetchFiles();
|
||||||
|
|
||||||
|
// Show preview immediately
|
||||||
|
previewUrl.value = '/static/' + path;
|
||||||
} else {
|
} else {
|
||||||
currentPath.value = '';
|
// It's likely a directory
|
||||||
|
currentPath.value = relativePath;
|
||||||
fetchFiles();
|
fetchFiles();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -483,11 +535,18 @@
|
|||||||
return new Date(ts * 1000).toLocaleString();
|
return new Date(ts * 1000).toLocaleString();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getDurationClass = (duration) => {
|
||||||
|
if (duration < 2.0) return 'text-green-600 bg-green-50';
|
||||||
|
if (duration < 5.0) return 'text-yellow-600 bg-yellow-50';
|
||||||
|
return 'text-red-600 bg-red-50';
|
||||||
|
};
|
||||||
|
|
||||||
const getTypeBadgeClass = (type) => {
|
const getTypeBadgeClass = (type) => {
|
||||||
const map = {
|
const map = {
|
||||||
'general': 'bg-blue-100 text-blue-800',
|
'general': 'bg-blue-50 text-blue-600 border border-blue-100',
|
||||||
'tarot': 'bg-purple-100 text-purple-800',
|
'tarot': 'bg-purple-50 text-purple-600 border border-purple-100',
|
||||||
'face': 'bg-pink-100 text-pink-800'
|
'tarot-recognize': 'bg-indigo-50 text-indigo-600 border border-indigo-100',
|
||||||
|
'face': 'bg-pink-50 text-pink-600 border border-pink-100'
|
||||||
};
|
};
|
||||||
return map[type] || 'bg-gray-100 text-gray-800';
|
return map[type] || 'bg-gray-100 text-gray-800';
|
||||||
};
|
};
|
||||||
@@ -508,7 +567,7 @@
|
|||||||
currentTab, history, files, currentPath,
|
currentTab, history, files, currentPath,
|
||||||
enterDir, navigateUp, deleteFile, triggerCleanup,
|
enterDir, navigateUp, deleteFile, triggerCleanup,
|
||||||
viewResult, previewImage, isImage, previewUrl,
|
viewResult, previewImage, isImage, previewUrl,
|
||||||
formatDate, getTypeBadgeClass, cleaning, deviceInfo,
|
formatDate, getDurationClass, getTypeBadgeClass, cleaning, deviceInfo,
|
||||||
currentModel, availableModels, updateModel,
|
currentModel, availableModels, updateModel,
|
||||||
cleanupConfig, saveCleanupConfig,
|
cleanupConfig, saveCleanupConfig,
|
||||||
prompts, fetchPrompts, savePrompt, getPromptDescription
|
prompts, fetchPrompts, savePrompt, getPromptDescription
|
||||||
|
|||||||
Reference in New Issue
Block a user