jiachenlong/backend/app/routers/ocr.py

771 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ocr - OCR识别路由
# Version: 0.0.1
# 更新:
import os
# 更新:
import uuid
# 更新:
import base64
# 更新:
import httpx
# 更新:
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
# 更新:
from sqlalchemy.orm import Session
# 更新:
from app.core.database import get_db
# 更新:
from app.core.auth import get_current_user
# 更新:
from app.models.models import User
# 更新:
# 更新:
router = APIRouter(prefix="/api/ocr", tags=["OCR 识别"])
# 更新:
# 更新:
# 阿里云 DashScope API 配置
# 更新:
DASHSCOPE_API_KEY = os.getenv("DASHSCOPE_API_KEY", "sk-9389024a37da4f7bb455ac9a6b28776f")
# 更新:
# 更新:
# 阿里云 OSS 配置
# 更新:
OSS_CONFIG = {
# 更新:
"access_key_id": os.getenv("OSS_ACCESS_KEY_ID", "LTAI5t6HUnpFBLEK9194kPVG"),
# 更新:
"access_key_secret": os.getenv("OSS_ACCESS_KEY_SECRET", "LEr4Q8yRxb8D5b24cKfCwlt4MMoke1"),
# 更新:
"bucket_name": "jiachenlong-oss",
# 更新:
"endpoint": "oss-cn-hangzhou.aliyuncs.com",
# 更新:
"public_url": "https://jiachenlong-oss.oss-cn-hangzhou.aliyuncs.com"
# 更新:
}
# 更新:
# 更新:
# 临时上传目录用于OCR识别本地备选
# 更新:
UPLOAD_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "uploads", "temp")
# 更新:
os.makedirs(UPLOAD_DIR, exist_ok=True)
# 更新:
# 更新:
# 更新:
def get_oss_path(file_type: str, user_id: str = None, collection_id: str = None, filename: str = None):
# 更新:
"""生成OSS路径 - 按年/月/日分类"""
# 更新:
from datetime import datetime
# 更新:
now = datetime.now()
# 更新:
year = now.strftime("%Y")
# 更新:
month = now.strftime("%m")
# 更新:
day = now.strftime("%d")
# 更新:
# 更新:
if file_type == "temp":
# 更新:
# 临时文件: temp/{year}/{month}/{day}/{uuid}.{ext}
# 更新:
import uuid
# 更新:
unique_id = str(uuid.uuid4())
# 更新:
ext = filename.split('.')[-1] if filename and '.' in filename else 'jpg'
# 更新:
return f"temp/{year}/{month}/{day}/{unique_id}.{ext}", unique_id
# 更新:
# 更新:
elif file_type == "collection":
# 更新:
# 藏品文件: collections/{user_id}/{year}/{collection_id}/{filename}
# 更新:
if not user_id or not collection_id:
# 更新:
raise ValueError("user_id and collection_id required for collection")
# 更新:
return f"collections/{user_id}/{year}/{collection_id}/{filename}"
# 更新:
# 更新:
elif file_type == "avatar":
# 更新:
# 头像: avatars/{user_id}/avatar.{ext}
# 更新:
if not user_id:
# 更新:
raise ValueError("user_id required for avatar")
# 更新:
ext = filename.split('.')[-1] if filename and '.' in filename else 'jpg'
# 更新:
return f"avatars/{user_id}/avatar.{ext}"
# 更新:
# 更新:
return None
# 更新:
# 更新:
# 更新:
# 上传图片到OSS - 使用服务层(带压缩)
# 更新:
from app.services.oss import upload_to_oss as oss_upload
# 更新:
# 更新:
def upload_to_oss(file_data, oss_key):
# 更新:
"""上传文件到阿里云OSS带自动压缩"""
# 更新:
return oss_upload(file_data, oss_key)
# 更新:
# 更新:
# 专业提示词
# 更新:
PROFESSIONAL_PROMPT = """你是一名专业的人民币生肖纪念钞鉴定专家。请严格按照以下步骤分析这张图片,并提取准确信息。
# 更新:
# 更新:
【识别流程】
# 更新:
1. 判断类型是否评级钞:首先确认是否为裸钞还是评级钞(有封装盒和标签)
# 更新:
2. 验证纪念钞特征:对照生肖纪念钞特征进行确认
# 更新:
3. 验证评级类型:有'标十'字眼的为标十,有'百连'字眼的为标百,其他为单张
# 更新:
4. 提取信息:仔细阅读标签上的所有文字内容
# 更新:
# 更新:
【版别格式要求】
# 更新:
只需要:年份 + 属相,例如:
# 更新:
- 2024 龙
# 更新:
- 2025 蛇
# 更新:
- 2026 马
# 更新:
# 更新:
【输出要求】
# 更新:
严格按照以下格式输出,每个字段必须填写具体值:
# 更新:
✅ 1 发行机构:中国人民银行
# 更新:
✅ 2 发行版别2024 龙
# 更新:
✅ 3 面额:贰拾圆
# 更新:
✅ 4 是否评级:是/否
# 更新:
✅ 5 封装类型:裸钞/单张/标十/标百
# 更新:
✅ 6 冠字序号J0xxxxxxxx
# 更新:
✅ 7 评级机构ACG/PCGS/PMG
# 更新:
✅ 8 评级分数67/68/69
# 更新:
✅ 9 是否三星:是/否
# 更新:
✅ 10 特殊标识:金山标/天马标/红绳版等
# 更新:
✅ 11 号码特征:金山号 2 张,天马号 3 张等
# 更新:
# 更新:
现在请仔细分析提供的图片,按上述格式输出结果。"""
# 更新:
# 更新:
# 更新:
@router.post("/recognize")
# 更新:
async def recognize_image(
# 更新:
image: UploadFile = File(...),
# 更新:
current_user: User = Depends(get_current_user),
# 更新:
db: Session = Depends(get_db)
# 更新:
):
# 更新:
"""OCR 图片识别 - 识别后自动保存图片到OSS临时目录"""
# 更新:
try:
# 更新:
# 读取图片数据
# 更新:
image_data = await image.read()
# 更新:
image_base64 = base64.b64encode(image_data).decode('utf-8')
# 更新:
# 更新:
# 生成OSS临时路径: temp/{year}/{month}/{day}/{uuid}.{ext}
# 更新:
oss_key, temp_id = get_oss_path("temp", filename=image.filename)
# 更新:
# 更新:
# 初始化temp_path为空
# 更新:
temp_path = None
# 更新:
# 更新:
# 上传到OSS
# 更新:
try:
# 更新:
image_url = upload_to_oss(image_data, oss_key)
# 更新:
except Exception as oss_err:
# 更新:
# OSS失败时保存到本地作为备选
# 更新:
temp_path = os.path.join(UPLOAD_DIR, oss_key.split('/')[-1])
# 更新:
os.makedirs(os.path.dirname(temp_path), exist_ok=True)
# 更新:
with open(temp_path, 'wb') as f:
# 更新:
f.write(image_data)
# 更新:
image_url = f"/uploads/temp/{oss_key.split('/')[-1]}"
# 更新:
# 更新:
headers = {
# 更新:
"Authorization": f"Bearer {DASHSCOPE_API_KEY}",
# 更新:
"Content-Type": "application/json"
# 更新:
}
# 更新:
# 更新:
# 阿里云 DashScope API 格式 (qwen-vl-plus 视觉模型)
# 更新:
payload = {
# 更新:
"model": "qwen-vl-plus",
# 更新:
"input": {
# 更新:
"messages": [{
# 更新:
"role": "user",
# 更新:
"content": [
# 更新:
{
# 更新:
"image": f"data:{image.content_type};base64,{image_base64}"
# 更新:
},
# 更新:
{
# 更新:
"text": PROFESSIONAL_PROMPT
# 更新:
}
# 更新:
]
# 更新:
}]
# 更新:
},
# 更新:
"parameters": {
# 更新:
"max_tokens": 1000
# 更新:
}
# 更新:
}
# 更新:
# 更新:
async with httpx.AsyncClient(timeout=60.0) as client:
# 更新:
response = await client.post(
# 更新:
"https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation",
# 更新:
json=payload,
# 更新:
headers=headers
# 更新:
)
# 更新:
# 更新:
if response.status_code != 200:
# 更新:
# 识别失败,删除临时文件
# 更新:
if temp_path and os.path.exists(temp_path):
# 更新:
os.remove(temp_path)
# 更新:
raise HTTPException(status_code=500, detail=f"OCR API 调用失败:{response.text[:200]}")
# 更新:
# 更新:
ocr_result = response.json()
# 更新:
text_content = ""
# 更新:
# 新版API返回格式
# 更新:
if "output" in ocr_result and "choices" in ocr_result["output"]:
# 更新:
choices = ocr_result["output"]["choices"]
# 更新:
if choices and len(choices) > 0:
# 更新:
content = choices[0].get("message", {}).get("content", [])
# 更新:
if content and len(content) > 0:
# 更新:
text_content = content[0].get("text", "")
# 更新:
# 更新:
fields = extract_fields(text_content)
# 更新:
# 更新:
# 更新用户AI识别次数
# 更新:
current_user.f99_95_ai_count = (current_user.f99_95_ai_count or 0) + 1
# 更新:
db.commit()
# 更新:
# 更新:
# 返回识别结果和临时图片路径
# 更新:
return {
# 更新:
"success": True,
# 更新:
"text": text_content,
# 更新:
"fields": fields,
# 更新:
"aiCount": current_user.f99_95_ai_count,
# 更新:
"temp_image": {
# 更新:
"id": temp_id,
# 更新:
"filename": oss_key.split('/')[-1],
# 更新:
"path": image_url,
# 更新:
"original_name": image.filename,
# 更新:
"is_oss": image_url.startswith("https://")
# 更新:
}
# 更新:
}
# 更新:
# 更新:
except Exception as e:
# 更新:
import traceback
# 更新:
error_detail = f"识别失败:{str(e)}\n{traceback.format_exc()}"
# 更新:
raise HTTPException(status_code=500, detail=error_detail)
# 更新:
# 更新:
# 更新:
def extract_fields(text: str) -> dict:
# 更新:
"""从 OCR 文本中提取字段 - 直接返回 AI 识别结果"""
# 更新:
import re
# 更新:
fields = {}
# 更新:
# 更新:
# 解析结构化输出
# 更新:
patterns = {
# 更新:
'issuer': r'✅.*?1.*?发行机构.*?[:]\s*(.+?)(?:\n|$)',
# 更新:
'version': r'✅.*?2.*?发行版别.*?[:]\s*(.+?)(?:\n|$)',
# 更新:
'denomination': r'✅.*?3.*?面额.*?[:]\s*(.+?)(?:\n|$)',
# 更新:
'is_graded_text': r'✅.*?4.*?是否评级.*?[:]\s*(.+?)(?:\n|$)',
# 更新:
'packaging': r'✅.*?5.*?封装类型.*?[:]\s*(.+?)(?:\n|$)',
# 更新:
'prefix_serial': r'✅.*?6.*?冠字序号.*?[:]\s*(.+?)(?:\n|$)',
# 更新:
'grading_company': r'✅.*?7.*?评级机构.*?[:]\s*(.+?)(?:\n|$)',
# 更新:
'grading_score': r'✅.*?8.*?评级分数.*?[:]\s*(.+?)(?:\n|$)',
# 更新:
'three_star_text': r'✅.*?9.*?是否三星.*?[:]\s*(.+?)(?:\n|$)',
# 更新:
'special_mark': r'✅.*?10.*?特殊标识.*?[:]\s*(.+?)(?:\n|$)',
# 更新:
'serial_feature': r'✅.*?11.*?号码特征.*?[:]\s*(.+?)(?:\n|$)'
# 更新:
}
# 更新:
# 更新:
for field, pattern in patterns.items():
# 更新:
match = re.search(pattern, text, re.IGNORECASE | re.DOTALL)
# 更新:
if match:
# 更新:
value = match.group(1).strip()
# 更新:
# 保留所有值,包括"无"和"未识别",让前端处理
# 更新:
fields[field] = value
# 更新:
# 更新:
# 处理是否评级
# 更新:
if 'is_graded_text' in fields:
# 更新:
fields['is_graded'] = '' in fields.pop('is_graded_text')
# 更新:
# 更新:
# 处理是否三星
# 更新:
if 'three_star_text' in fields:
# 更新:
fields['three_star'] = '' in fields.pop('three_star_text')
# 更新:
# 更新:
# 简化版别字段2024 龙年贺岁纪念钞(标十) → 2024 龙)
# 更新:
if 'version' in fields:
# 更新:
version = fields['version']
# 更新:
# 提取年份和生肖
# 更新:
year_match = re.search(r'(20\d{2})', version)
# 更新:
animal = ''
# 更新:
if '' in version:
# 更新:
animal = ''
# 更新:
elif '' in version:
# 更新:
animal = ''
# 更新:
elif '' in version:
# 更新:
animal = ''
# 更新:
elif '' in version:
# 更新:
animal = ''
# 更新:
elif '' in version:
# 更新:
animal = ''
# 更新:
elif '' in version:
# 更新:
animal = ''
# 更新:
elif '' in version:
# 更新:
animal = ''
# 更新:
elif '' in version:
# 更新:
animal = ''
# 更新:
elif '' in version:
# 更新:
animal = ''
# 更新:
elif '' in version:
# 更新:
animal = ''
# 更新:
elif '' in version:
# 更新:
animal = ''
# 更新:
elif '' in version:
# 更新:
animal = ''
# 更新:
# 更新:
if year_match and animal:
# 更新:
fields['version'] = f"{year_match.group(1)}{animal}"
# 更新:
# 更新:
return fields
# 更新:
# 更新:
# 更新:
@router.post("/claim-temp-image")
# 更新:
async def claim_temp_image(
# 更新:
temp_id: str,
# 更新:
collection_id: str,
# 更新:
current_user: User = Depends(get_current_user),
# 更新:
db: Session = Depends(get_db)
# 更新:
):
# 更新:
"""将临时图片移动到正式藏品目录 - 按用户/年/藏品ID分类"""
# 更新:
from app.models.models import Collection, CollectionImage
# 更新:
from datetime import datetime
# 更新:
# 更新:
# 验证藏品是否存在
# 更新:
collection = db.query(Collection).filter(
# 更新:
Collection.f99_90_id == collection_id,
# 更新:
Collection.f99_91_user_id == current_user.f99_90_id
# 更新:
).first()
# 更新:
# 更新:
if not collection:
# 更新:
raise HTTPException(status_code=404, detail="藏品不存在")
# 更新:
# 更新:
# 生成正式文件名和OSS路径: collections/{user_id}/{year}/{collection_id}/{filename}
# 更新:
code = collection.f01_02_code or "0000"
# 更新:
prefix = collection.f02_10_prefix_serial or ""
# 更新:
username = current_user.f01_01_name
# 更新:
import time
# 更新:
final_filename = f"{username}-{code}-{prefix}-{int(time.time())}.jpg"
# 更新:
oss_key = get_oss_path("collection", user_id=current_user.f99_90_id, collection_id=collection_id, filename=final_filename)
# 更新:
# 更新:
# 尝试从OSS获取临时图片 - 尝试多种扩展名和日期路径
# 更新:
temp_extensions = ['jpg', 'jpeg', 'png', 'gif', 'JPG', 'JPEG', 'PNG', 'GIF']
# 更新:
temp_content = None
# 更新:
found_key = None
# 更新:
# 更新:
# 尝试最近7天的路径
# 更新:
from datetime import timedelta
# 更新:
for i in range(7):
# 更新:
date = datetime.now() - timedelta(days=i)
# 更新:
year = date.strftime("%Y")
# 更新:
month = date.strftime("%m")
# 更新:
day = date.strftime("%d")
# 更新:
# 更新:
for ext in temp_extensions:
# 更新:
try:
# 更新:
temp_oss_key = f"temp/{year}/{month}/{day}/{temp_id}.{ext}"
# 更新:
import oss2
# 更新:
auth = oss2.Auth(OSS_CONFIG["access_key_id"], OSS_CONFIG["access_key_secret"])
# 更新:
bucket = oss2.Bucket(auth, OSS_CONFIG["endpoint"], OSS_CONFIG["bucket_name"])
# 更新:
temp_content = bucket.get_object(temp_oss_key).read()
# 更新:
found_key = temp_oss_key
# 更新:
break
# 更新:
except:
# 更新:
continue
# 更新:
if temp_content:
# 更新:
break
# 更新:
# 更新:
if temp_content:
# 更新:
# 上传到正式目录
# 更新:
bucket.put_object(oss_key, temp_content)
# 更新:
# 更新:
# 删除临时图片
# 更新:
try:
# 更新:
bucket.delete_object(found_key)
# 更新:
except:
# 更新:
pass
# 更新:
# 更新:
# OSS URL
# 更新:
image_path = f"{OSS_CONFIG['public_url']}/{oss_key}"
# 更新:
# 更新:
else:
# 更新:
# OSS失败使用本地文件
# 更新:
temp_path = None
# 更新:
for ext in temp_extensions:
# 更新:
temp_path = os.path.join(UPLOAD_DIR, f"{temp_id}.{ext}")
# 更新:
if os.path.exists(temp_path):
# 更新:
break
# 更新:
# 更新:
if not temp_path or not os.path.exists(temp_path):
# 更新:
raise HTTPException(status_code=404, detail="临时图片不存在或已过期")
# 更新:
# 更新:
# 保存到本地
# 更新:
collection_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "uploads", "collections")
# 更新:
os.makedirs(collection_dir, exist_ok=True)
# 更新:
# 更新:
new_path = os.path.join(collection_dir, final_filename)
# 更新:
import shutil
# 更新:
shutil.move(temp_path, new_path)
# 更新:
image_path = f"uploads/collections/{final_filename}"
# 更新:
# 更新:
# 创建图片记录
# 更新:
image_record = CollectionImage(
# 更新:
id=str(uuid.uuid4()),
# 更新:
collection_id=collection.f99_90_id,
# 更新:
filename=final_filename,
# 更新:
original_name=temp_id,
# 更新:
path=image_path
# 更新:
)
# 更新:
db.add(image_record)
# 更新:
db.commit()
# 更新:
# 更新:
return {
# 更新:
"success": True,
# 更新:
"image": {
# 更新:
"id": image_record.id,
# 更新:
"filename": image_record.filename,
# 更新:
"path": image_record.path
# 更新:
}
# 更新:
}
# 更新: