新增证件识别接口

This commit is contained in:
BBIT-Kai
2025-10-29 13:53:55 +08:00
parent a1f0d0ad55
commit aff1b85ab0
14 changed files with 720 additions and 74 deletions
+152
View File
@@ -0,0 +1,152 @@
from typing import TypedDict
from langchain_core.messages import HumanMessage
from langgraph.graph import StateGraph, END
from config.llm import llmVision
# -------- 定义状态 --------
class State(TypedDict):
image_url: str # 证件照
type: int # 证件类别
content: str # 最终内容
def analyze_card(state: State, prompt_text: str):
messages = [
HumanMessage(
content=[
{"type": "text", "text": prompt_text},
{"type": "image_url", "image_url": {"url": state["image_url"]}},
]
)
]
return llmVision.invoke(messages).content
# -------- 定义节点 --------
def decide_source(state: State, max_retry=3):
choice = analyze_card(
state,
"""
请根据图片内容判断其中的证件类型,并输出对应代号。
代号说明:
-1: 并非证件
0:身份证
1:银行卡
只输出代号数字,不要输出任何文字、标点或解释。
你的输出是:
""",
)
print("图片类型是", choice)
try:
choice = int(choice)
state["type"] = choice
if choice == -1:
state["content"] = '{"result": "暂不支持此证件"}'
except ValueError:
state["type"] = -1
state["content"] = '{"result": "暂不支持此证件"}'
return state
def idcard(state: State):
state["content"] = analyze_card(
state,
"""
你是一个 OCR 信息提取专家,请从图片中识别出身份证的全部可见信息,并输出严格的 JSON 格式。
要求:
1. 无论是正面还是反面,都要识别所有字段。
2. 如果某个字段无法辨认,请将值设为 null。
3. 只输出 JSON,不要输出任何解释或多余内容。
4. 严格保持 JSON 格式,键名统一为英文。
5. "side" 字段为身份证方向,填写:人像面 或 国徽面。
JSON 示例格式:
{
"side": "国徽面",
"name": "持证人姓名",
"gender": "性别",
"ethnicity": "民族",
"id_number": "身份证号",
"birth_date": "出生日期",
"address": "住址",
"issuing_authority": "签发机关",
"valid_period_start": "有效期开始日期",
"valid_period_end": "有效期结束日期",
"notes": "其他可见文字或备注"
}
请确保输出的 JSON 可以被严格解析。
""",
)
return state
def bankcard(state: State):
state["content"] = analyze_card(
state,
"""
你是一个 OCR 信息提取专家,请从图片中识别出银行卡上的全部可见信息,并输出严格的 JSON 格式。
要求:
1. 识别卡号、发卡行、持卡人、有效期等关键信息。
2. 如果某个字段无法辨认,请将值设为 null。
3. 只输出 JSON,不要输出任何解释或附加内容。
4. 严格保持 JSON 格式,键名统一为英文。
JSON 示例格式:
{
"bank_name": "发卡行名称",
"card_number": "卡号",
"card_holder": "持卡人姓名",
"expiry_date": "有效期",
"card_type": "卡种类型(如借记卡/信用卡/Visa/MasterCard",
"issuer_country": "发卡国家",
"notes": "其他可见文字或符号"
}
请确保输出的 JSON 可以被严格解析。
""",
)
return state
# ------------------------------------------------------------------------ 构建有向图 --------
workflow = StateGraph(State)
workflow.add_node("decide", decide_source)
workflow.add_node("idcard", idcard)
workflow.add_node("bankcard", bankcard)
workflow.set_entry_point("decide")
# 条件边:根据 path 决定走向
workflow.add_conditional_edges(
"decide",
lambda state: state["type"],
{
-1: END,
0: "idcard",
1: "bankcard",
},
)
workflow.add_edge("idcard", END)
workflow.add_edge("bankcard", END)
graph = workflow.compile()
import re
# 执行函数
def get_license_response(image_url: str):
final_state = graph.invoke(
{
"image_url": image_url,
}
)
# 去掉 ```json 和 ``` 包裹
final_state["content"] = final_state["content"]
final_state["content"] = re.sub(
r"^```json\s*|\s*```$", "", final_state["content"].strip()
)
print("最终识别内容:", final_state["content"])
return final_state
-60
View File
@@ -1,60 +0,0 @@
from typing import Optional
from pydantic import BaseModel, Field
from langchain_core.messages import HumanMessage
from config.llm import *
from langchain.prompts import PromptTemplate
from langchain.prompts import PromptTemplate
from langchain.schema import HumanMessage
import os
import base64
from langchain.prompts import PromptTemplate
from langchain_openai import ChatOpenAI
from langchain_core.output_parsers import JsonOutputParser
from langchain.schema import HumanMessage
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field
import json
import re
import json
import requests
import cv2
import numpy as np
import requests
class CocoonSample(BaseModel):
moisture_content: float = Field(
...,
description="茧的含水量,单位为百分比(%),浮点数"
)
cocoon_weight: float = Field(
...,
description="下足茧的重量,单位为克,可带小数"
)
defective_pupa_count: int = Field(
...,
description="非好蛹粒数,即不合格蛹的数量,整数"
)
fresh_shell_weight: float = Field(
...,
description="鲜壳重量,单位为克,可带小数"
)
sample_count: int = Field(
...,
description="小样粒数,用于检测的茧粒数,整数"
)
net_weight_total: float = Field(
...,
description="所有样品的净重合计,单位为克,浮点数"
)
evaluator: Optional[str] = Field(
None,
description="仪评人姓名,可能为空"
)
reviewer: Optional[str] = Field(
None,
description="复核人员姓名,可能为空"
)
+77 -1
View File
@@ -1,4 +1,3 @@
import json
from typing import List, Dict
from langchain_postgres import PostgresChatMessageHistory
@@ -559,3 +558,80 @@ def insert_ticket_image(
new_id = cursor.fetchone()[0]
conn.commit()
return new_id
import json
# ————————————————————————————————————————————————————证件照片识别———————————————————————————————
def insert_license_image(
created_by, file_name, resolution, size, name, oss, type, content
):
with pg_pool.getConn() as conn:
with conn.cursor() as cursor:
cursor.execute(
"""
INSERT INTO license_images (
created_by, created_at, file_name, resolution, size, name, oss,
type, content
)
VALUES (%s, NOW(), %s, %s, %s, %s, %s, %s, %s )
RETURNING id
""",
(created_by, file_name, resolution, size, name, oss, type, content),
)
new_id = cursor.fetchone()[0]
conn.commit()
return new_id
def get_license_image_list(user_id, page=1, page_size=10):
"""
获取用户已分析图片列表,带分页
"""
offset = (page - 1) * page_size
with pg_pool.getConn() as conn:
with conn.cursor() as cursor:
# 1️⃣ 查询总条数
cursor.execute(
"""
SELECT COUNT(*)
FROM license_images
WHERE created_by = %s
""",
(user_id,),
)
total = cursor.fetchone()[0]
# 2️⃣ 查询当前页数据
cursor.execute(
"""
SELECT created_at, file_name, resolution, size, name, oss, id, type, content
FROM license_images
WHERE created_by = %s
ORDER BY created_at DESC
LIMIT %s OFFSET %s
""",
(user_id, page_size, offset),
)
rows = cursor.fetchall()
result = []
for row in rows:
result.append(
{
"created_at": MyUtils.format_datetime(row[0]),
"file_name": row[1],
"resolution": row[2],
"size": round(row[3] / 1024, 2),
"name": row[4],
"oss_url": get_temp_url("image-license", row[5]),
"id": row[6],
"type": row[7],
"content": row[8],
}
)
return total, result
+26 -1
View File
@@ -4,8 +4,9 @@ from fastapi import APIRouter
from config.app import F8_SERVER_USER_ID
from models.BaseResponse import BaseResponse
from models.F8ImageRequest import F8ImageRequest
from models.F8ImageRequestV2 import F8ImageRequestV2
from service.vision import process_ticket_image
from service.vision import process_ticket_image, process_license_image
from utils import MyUtils
publicRouter = APIRouter()
@@ -30,3 +31,27 @@ async def cocoonTicket(data: F8ImageRequestV2):
return BaseResponse(data=json_data)
except Exception as e:
return BaseResponse(status=False, message=f"解析失败: {str(e)}", data=None)
import json
@publicRouter.post("/recognize-license")
async def cocoon_license(data: F8ImageRequest):
input_data = data.image
if "," in input_data:
input_data = input_data.split(",")[1]
try:
img_bytes = base64.b64decode(input_data)
json_data = await MyUtils.async_task(
process_license_image,
img_bytes,
f"{data.title}.jpg",
data.title,
F8_SERVER_USER_ID,
)
data = json.loads(json_data.get("content"))
data["type"] = json_data.get("type")
return BaseResponse(data=data)
except Exception as e:
return BaseResponse(status=False, message=f"解析失败: {str(e)}", data=None)
+56 -3
View File
@@ -1,13 +1,13 @@
from uuid import UUID
from fastapi import APIRouter, File, Form, Depends
from fastapi import APIRouter, File, Form, Depends, Query
import db.postgres as pg
from config.security import get_user_id_from_token
from llm.ticketLLM import *
from models.BaseResponse import BaseResponse
from models.ImageRequest import ImageRequest
from service.vision import process_ticket_image
from service.vision import process_ticket_image, process_license_image
from utils import MyUtils
visionRouter = APIRouter()
@@ -51,6 +51,25 @@ def cocoonTicket(user_id: UUID = Depends(get_user_id_from_token)):
return BaseResponse(data=pg.get_ticket_image_list(user_id))
@visionRouter.post("/createTicketImageTaskV2")
async def createTicketImageTask(
file: UploadFile = File(...),
projectName: str = Form(...),
user_id: UUID = Depends(get_user_id_from_token),
):
if not user_id:
return {"error": "userId is required"}
try:
contents = await file.read()
await MyUtils.async_task(
process_ticket_image, 2, True, contents, file.filename, projectName, user_id
)
return BaseResponse(data=None)
except Exception as e:
print(str(e))
return BaseResponse(status=False, message=f"解析失败: {str(e)}", data=None)
@visionRouter.post("/createTicketImageTaskV2")
async def createTicketImageTask(
file: UploadFile = File(...),
@@ -66,5 +85,39 @@ async def createTicketImageTask(
)
return BaseResponse(data=json_data)
except Exception as e:
print(str(e))
return BaseResponse(status=False, message=f"解析失败: {str(e)}", data=None)
@visionRouter.post("/createLicenseImageTask")
async def createLicenseImageTask(
file: UploadFile = File(...),
projectName: str = Form(...),
user_id: UUID = Depends(get_user_id_from_token),
):
if not user_id:
return {"error": "userId is required"}
try:
contents = await file.read()
await MyUtils.async_task(
process_license_image, contents, file.filename, projectName, user_id
)
return BaseResponse(data=None)
except Exception as e:
return BaseResponse(status=False, message=f"解析失败: {str(e)}", data=None)
@visionRouter.get("/getLicenseImageList")
def cocoonLicense(
user_id: UUID = Depends(get_user_id_from_token),
page: int = Query(1, ge=1),
page_size: int = Query(10, ge=1, le=100),
):
if not user_id:
return {"error": "userId is required"}
total, items = pg.get_license_image_list(user_id, page=page, page_size=page_size)
return BaseResponse(
data={
"total": total,
"items": items,
}
)
+41
View File
@@ -3,6 +3,7 @@ from uuid import UUID
import config.minIO as minIO
import db.postgres as pg
from agent.licenseImageAgent import get_license_response
from config.minIO import minio_client
from llm.ticketLLM import *
from llm.ticketLLMv2 import get_ticket_response_v2
@@ -65,3 +66,43 @@ def process_ticket_image(
)
return json_data
def process_license_image(
img_bytes=None,
file_name: str = None,
project_name: str = None,
user_id: UUID = None,
):
# 上传到 OSS,使用 UUID 做对象名
if img_bytes is None:
img_bytes = []
object_name = str(uuid.uuid4())
file_bytes = BytesIO(img_bytes)
bucket_name = "image-license"
if not minio_client.bucket_exists(bucket_name):
minio_client.make_bucket(bucket_name)
minIO.push_file(bucket_name, object_name, file_bytes, img_bytes, "image/jpeg")
oss_url = minIO.get_temp_url(bucket_name, object_name)
# 调用分析方法获取 JSON
json_data = get_license_response(oss_url)
# 获取图片分辨率和大小
img = Image.open(BytesIO(img_bytes))
resolution = f"{img.width}x{img.height}"
size_kb = round(len(img_bytes) / 1024, 2)
# 插入数据库
pg.insert_license_image(
created_by=user_id,
file_name=file_name,
resolution=resolution,
size=size_kb,
name=project_name if project_name else object_name[:8],
oss=object_name,
type=json_data.get("type"),
content=json_data.get("content"),
)
return json_data
+10 -2
View File
@@ -70,7 +70,7 @@
| | 9100 | | ce_node_exporter | Prometheus | Prometheus监控主机的工具 |
| | | | | | |
| | | | | | |
| **13011** | 13011 | 13011 | Harbor | Harbor | Harboradmin:bbit |
| **13011** | 13011 | 13011 | Harbor | Harbor | Harboradmin:Bbit000000 |
| 8088 | | | | | 建议后续关闭,原Android远程框架,现已由网关控制 |
| 8089 | | | | | 建议后续关闭,原Ktor后端服务,现已由网关控制 |
@@ -176,7 +176,7 @@ docker image prune -f
4. (2x客户端)登录
```
docker login http://s1.ronsunny.cn:13011
docker login ai.ronsunny.cn:13011
```
- Prometheus
@@ -226,6 +226,14 @@ docker image prune -f
## 五、其他
docker 权限
sudo groupadd docker
sudo usermod -aG docker kaijihar
newgrp docker
### 旧部署
后端
+1
View File
@@ -1,3 +1,4 @@
export * from './iva';
export * from './license';
export * from './sca';
export * from './ticket';
+21
View File
@@ -0,0 +1,21 @@
import { pyRequestClient } from '#/api/request';
/**
* 获取已分析的图片列表
*/
export async function refreshLicenseImageList(page = 1, pageSize = 10) {
return pyRequestClient.get('/llm/getLicenseImageList', {
params: { page, page_size: pageSize },
});
}
/**
* 上传图片分析任务
*/
export async function createLicenseImageTask(formData: FormData) {
return pyRequestClient.post('/llm/createLicenseImageTask', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
}
@@ -7,7 +7,7 @@ const routes: RouteRecordRaw[] = [
{
meta: {
icon: 'ic:round-remove-red-eye',
authority: ['iva', 'sca', 'ysa', 'ticket'],
authority: ['iva', 'sca', 'ysa', 'ticket', 'license'],
keepAlive: true,
order: 2,
title: $t('计算机视觉'),
@@ -59,6 +59,17 @@ const routes: RouteRecordRaw[] = [
},
component: () => import('#/views/cv/ticket/index.vue'),
},
{
name: 'LICENSE',
path: '/cv/license',
meta: {
authority: ['license'],
icon: 'mdi:certificate',
title: $t('证件照片分析'),
keepAlive: true,
},
component: () => import('#/views/cv/license/index.vue'),
},
{
name: 'CVAT',
path: '/cv/cvat',
@@ -38,7 +38,7 @@ const routes: RouteRecordRaw[] = [
component: IFrameView,
meta: {
icon: 'mdi:wall-fire',
iframeSrc: 'http://ai.ronsunny.cn:13010/',
iframeSrc: 'http://s1.ronsunny.cn:13010/',
keepAlive: false,
title: 'RAG Flow',
},
@@ -0,0 +1,312 @@
<script setup lang="ts">
import { computed, onDeactivated, onMounted, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Button, Form, Input, message } from 'ant-design-vue';
import * as api from '#/api';
const list = ref<any[]>([]);
const error = ref<null | string>(null);
const selectedItem = ref<any>(null);
async function loadList() {
error.value = null;
const res =
(await api.refreshLicenseImageList(page.value, pageSize.value)) || [];
list.value = res.items;
total.value = res.total;
}
function createTask() {
modalApi.open();
}
// 分页参数
const page = ref(1);
const pageSize = ref(9);
const total = ref(0); // 总条数
// 计算总页数
const totalPages = computed(() => Math.ceil(total.value / pageSize.value));
function changePage(newPage) {
page.value = newPage;
loadList();
}
async function selectItem(item: any) {
selectedItem.value = item;
refreshLineChart();
}
onMounted(() => {
loadList();
});
const showInfoStr = ref<Record<string, number | string>>({});
const showInfoStr2 = ref<Record<string, number | string>>({});
// key 映射表
const keyMap: Record<string, string> = {
// 身份证
side: '证件面',
name: '姓名',
gender: '性别',
ethnicity: '民族',
id_number: '身份证号',
birth_date: '出生日期',
address: '住址',
issuing_authority: '签发机关',
valid_period_start: '开始日期',
valid_period_end: '结束日期',
notes: '备注',
// 银行卡
bank_name: '发卡行',
card_number: '卡号',
card_holder: '持卡人',
expiry_date: '有效期',
card_type: '卡种',
result: '识别结果',
issuer_country: '发卡国家',
};
// 递归替换函数
function translateKeys(obj: any, map: Record<string, string>): any {
if (Array.isArray(obj)) {
return obj.map((item) => translateKeys(item, map));
} else if (obj !== null && typeof obj === 'object') {
const newObj: Record<string, any> = {};
for (const key in obj) {
const newKey = map[key] || key; // 没映射就用原来的 key
newObj[newKey] = translateKeys(obj[key], map);
}
return newObj;
}
return obj; // 基本类型直接返回
}
function refreshLineChart() {
const data = selectedItem.value;
showInfoStr.value = {
项目名: data.name,
上传时间: data.created_at,
文件名: data.file_name,
文件大小: `${data.size} MB`,
分辨率: data.resolution,
证件类型:
data.type === -1 ? '未知证件' : data.type === 0 ? '身份证' : '银行卡',
};
showInfoStr2.value = translateKeys(data.content, keyMap);
}
onDeactivated(() => {
// 离开路由时清理状态
selectedItem.value = null;
showInfoStr.value = {};
});
const projectName = ref('');
const fileName = ref('');
const selectedFile = ref<File | null>(null);
const fileInputRef = ref<HTMLInputElement | null>(null);
const [Modal, modalApi] = useVbenModal({
title: '新建证件照片分析任务',
class: 'w-[600px]',
onCancel() {
modalApi.close();
},
onConfirm() {
if (!selectedFile.value) {
message.warning('请选择证件照片');
return;
}
uploadFile();
},
});
async function uploadFile() {
if (!selectedFile.value) {
message.warning('请选择证件照');
return;
}
// 先关闭弹窗
modalApi.close();
try {
const formData = new FormData();
formData.append('file', selectedFile.value);
formData.append('projectName', projectName.value);
await api.createLicenseImageTask(formData);
// 接口完成后再触发事件
message.success('分析任务完成');
loadList();
// 清空表单
selectedFile.value = null;
projectName.value = '';
fileName.value = '';
} catch {
message.error('上传失败');
}
}
function selectFile() {
fileInputRef.value?.click();
}
function handleFileChange(event: Event) {
const files = (event.target as HTMLInputElement).files;
if (files && files.length > 0) {
selectedFile.value = files[0];
fileName.value = files[0].name;
}
}
</script>
<template>
<div class="flex h-[90dvh] w-full flex-col">
<Modal>
<Form layout="vertical">
<Form.Item label="任务名称">
<Input v-model:value="projectName" placeholder="可为空,将取随机值" />
</Form.Item>
<Form.Item label="证件照片" required>
<div
@click="selectFile"
style="
padding: 16px;
text-align: center;
cursor: pointer;
border: 1px dashed #d9d9d9;
"
>
{{ fileName || '点击选择文件' }}
<input
type="file"
accept="image/*"
ref="fileInputRef"
@change="handleFileChange"
style="display: none"
/>
</div>
</Form.Item>
</Form>
</Modal>
<BaseModal />
<div class="flex h-full w-full bg-gray-50">
<!-- 左侧筛选 + 列表 -->
<div class="flex w-64 flex-col border-r bg-white p-4">
<!-- 按钮组 -->
<div class="mb-4 flex justify-between space-x-2">
<Button type="primary" @click="createTask" class="flex-1">
新建任务
</Button>
</div>
<!-- 列表 -->
<div class="flex-1 space-y-2 overflow-auto">
<div
v-for="item in list"
:key="item.v_id"
@click="selectItem(item)"
class="cursor-pointer rounded border p-3 hover:bg-gray-100"
:class="{ 'bg-gray-100': item.id === selectedItem?.id }"
>
<div class="text-base font-medium">{{ item.name }}</div>
<div class="text-sm text-gray-400">{{ item.created_at }}</div>
</div>
</div>
<!-- 分页 -->
<div class="mt-2 flex justify-center space-x-2">
<button
:disabled="page === 1"
@click="changePage(page - 1)"
class="rounded border px-1"
>
上一页
</button>
<span> {{ page }} / {{ totalPages }} </span>
<button
:disabled="page === totalPages"
@click="changePage(page + 1)"
class="rounded border px-1"
>
下一页
</button>
</div>
</div>
<!-- 右侧Tab 内容区 -->
<div class="flex flex-1 flex-col overflow-hidden p-6">
<div
v-if="!selectedItem"
class="flex h-full items-center justify-center text-gray-400"
>
请先选择左侧列表中的分析任务
</div>
<template v-else>
<div class="flex h-full flex-col gap-4">
<!-- 主内容区域左右结构 -->
<div class="flex flex-1 gap-4">
<!-- 左侧 -->
<div class="flex w-72 flex-col gap-4">
<!-- 视频基础信息展示 -->
<div
class="w-full rounded border bg-white p-4"
id="video_base_info"
>
<div
v-for="(value, key) in showInfoStr"
:key="key"
class="mb-2 flex text-sm text-gray-700"
>
<div class="w-28 font-medium text-gray-900">
{{ key }}
</div>
<div class="flex-1 break-all text-gray-600">
{{ value || '—' }}
</div>
</div>
</div>
<!-- 空白卡片 -->
<div class="flex-1 rounded border bg-white p-4">
<div
v-for="(value, key) in showInfoStr2"
:key="key"
class="mb-2 flex text-sm text-gray-700"
>
<div class="w-32 font-medium text-gray-900">
{{ key }}
</div>
<div class="flex-1 break-all text-gray-600">
{{ value || '—' }}
</div>
</div>
</div>
</div>
<!-- 右侧 -->
<div class="flex flex-1 flex-col gap-4">
<!-- 左右两个图片显示 -->
<!-- 左图 -->
<div
class="flex h-full w-full items-center justify-center rounded border bg-white p-4"
>
<img
:src="selectedItem?.oss_url"
alt="左图"
class="h-[80dvh] w-full object-contain"
/>
</div>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
</template>
@@ -94,15 +94,14 @@ async function uploadFile() {
formData.append('file', selectedFile.value);
formData.append('projectName', projectName.value);
await api.createTicketImageTask(formData);
// 接口完成后再触发事件
message.success('分析任务完成');
loadList();
// 清空表单
selectedFile.value = null;
projectName.value = '';
fileName.value = '';
// 接口完成后再触发事件
message.success('分析任务完成');
loadList();
} catch {
message.error('上传失败');
}
@@ -43,6 +43,13 @@ const cv: WorkbenchQuickNavItem[] = [
title: '仪评指标联分析',
url: '/cv/ticket',
},
{
color: '#3fb27f',
authority: ['license'],
icon: 'mdi:certificate',
title: '证件照片分析',
url: '/cv/license',
},
{
color: '#3fb27f',
icon: 'ion:bar-chart-outline',