后端新增《蚕茧识别V2》模块

This commit is contained in:
BBIT-Kai
2025-11-10 18:08:50 +08:00
parent 9527cc2f1c
commit 625d185f69
15 changed files with 559 additions and 809 deletions
+7 -4
View File
@@ -4,6 +4,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from uvicorn import Config, Server
from config.yolo import YOLOSingleton
from mcp_local.mcp_pipe import init_mcp_server
from routers.Bot import botRouter
from routers.Chat import chatRouter
@@ -21,7 +22,7 @@ async def ai_lab():
app = FastAPI(title="BBIT_AI")
origins = [
"http://localhost:8090", # Vite dev 默认端口
"http://localhost:8091", # Vite dev 默认端口
"https://ai.ronsunny.cn:8090",
"*", # ⚠️ 生产环境不要用
]
@@ -40,11 +41,11 @@ async def ai_lab():
reportDataRouter,
serviceRouter,
botRouter,
visionRouter,
rqRouter,
]
for r in routers:
app.include_router(r, prefix="/llm", tags=["llm"])
app.include_router(visionRouter, prefix="/cv", tags=["cv"])
app.include_router(publicRouter, prefix="/api/public", tags=["api"])
config = Config(app=app, host="0.0.0.0", port=13011, log_level="info")
server = Server(config)
@@ -52,12 +53,14 @@ async def ai_lab():
async def main():
# 初始化模型
YOLOSingleton.init_model()
# 主干AI实验室FastAPI服务
task_api = asyncio.create_task(ai_lab())
# MCP服务-ailab
# endpoint_url = "wss://ai.ronsunny.cn:8090/aimcp/mcp_endpoint/mcp/?token=TsSP9lBq6Oa1WMkachHoS2TtNt4GKV/Gli24pk5Rjpk%3D"
endpoint_url_ai_lab = "ws://ce_bot_mcp:8004/mcp_endpoint/mcp/?token=TsSP9lBq6Oa1WMkachHoS2TtNt4GKV/Gli24pk5Rjpk%3D"
endpoint_url_ai_lab = "wss://ai.ronsunny.cn:8090/aimcp/mcp_endpoint/mcp/?token=TsSP9lBq6Oa1WMkachHoS2TtNt4GKV/Gli24pk5Rjpk%3D"
# endpoint_url_ai_lab = "ws://ce_bot_mcp:8004/mcp_endpoint/mcp/?token=TsSP9lBq6Oa1WMkachHoS2TtNt4GKV/Gli24pk5Rjpk%3D"
task_mcp1 = asyncio.create_task(init_mcp_server(endpoint_url_ai_lab))
# MCP服务-ql
+2 -1
View File
@@ -1,8 +1,9 @@
from langchain_milvus import Milvus
from config.llm import llmEmbeddings
from utils.GlobalVariable import LOCAL_IP
URI = "http://ce_milvus:19530"
URI = "http://" + LOCAL_IP + ":19530"
knVectorstore = Milvus(
embedding_function=llmEmbeddings,
+3 -1
View File
@@ -5,6 +5,8 @@ from contextlib import contextmanager
import psycopg
from psycopg_pool import ConnectionPool
from utils.GlobalVariable import LOCAL_IP
logger = logging.getLogger("PGPool")
logger.setLevel(logging.INFO)
@@ -66,7 +68,7 @@ class PGPool:
pg_pool = PGPool(
uri="postgresql://postgres:123456@ce_postgres/ktor2",
uri="postgresql://postgres:123456@" + LOCAL_IP + "/ktor2",
min_size=1,
max_size=20,
)
+3 -1
View File
@@ -1,4 +1,6 @@
RABBIT_HOST = "ce_rabbitmq"
from utils.GlobalVariable import LOCAL_IP
RABBIT_HOST = LOCAL_IP
RABBIT_VHOST = "/bbit_ai"
RABBIT_USER = "bbit_ai"
RABBIT_PASSWORD = "123456"
+156
View File
@@ -0,0 +1,156 @@
import logging
import cv2
import numpy as np
import torch
from PIL import Image, ImageDraw, ImageFont
from ultralytics import YOLO
from utils import MyUtils
def draw_annotations(
img_bgr,
boxes,
labels,
confidences=None,
font_path="/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
):
"""
绘制带中文、置信度、不同颜色的标注框
:param img_bgr: np.ndarray, BGR 图像
:param boxes: list of xyxy
:param labels: list of str
:param confidences: list of float, 可选,用于显示置信度
:param font_path: 字体路径
:return: np.ndarray, BGR 标注图
"""
h, w, _ = img_bgr.shape
line_width = max(2, int(w / 400))
font_size = max(20, int(w / 40))
img = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
pil_img = Image.fromarray(img)
draw = ImageDraw.Draw(pil_img)
font = ImageFont.truetype(font_path, font_size)
# 定义类别颜色映射
color_map = {
"正茧": "green",
"双宫茧": "grey",
"黄斑茧": "red",
"毛茧": "white",
"蛆壳茧": "purple",
}
for idx, (b, label) in enumerate(zip(boxes, labels), start=1):
conf = confidences[idx - 1] if confidences else None
display_text = f"{label}#{idx}"
if conf is not None:
display_text += f" {conf:.2f}"
x1, y1, x2, y2 = map(int, b)
box_color = color_map.get(label, "red")
# 画框
draw.rectangle([x1, y1, x2, y2], outline=box_color, width=line_width)
# 文本边界
bbox = draw.textbbox((0, 0), display_text, font=font)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
# 文本背景
draw.rectangle(
[x1, y1 - text_height - 4, x1 + text_width + 4, y1], fill="black"
)
draw.text((x1 + 2, y1 - text_height - 2), display_text, fill="white", font=font)
annotated = cv2.cvtColor(np.array(pil_img), cv2.COLOR_RGB2BGR)
return annotated
logger = logging.getLogger("yolo_service")
class YOLOSingleton:
_model = None
_ready = False
@classmethod
def instance(cls):
"""获取模型单例"""
if cls._model is None:
logger.warning("模型尚未初始化")
return cls._model
@classmethod
def is_ready(cls):
return cls._ready
@classmethod
def init_model(cls):
"""初始化模型,失败时不抛出异常"""
try:
cls._model = YOLO("/app/models/yolo/yolo_silkworm_cocoon_detect_v1.pt")
cls._model.to("cuda" if torch.cuda.is_available() else "cpu")
cls._ready = True
logger.info("✅ YOLO 模型加载完成")
except Exception as e:
cls._model = None
cls._ready = False
logger.error(f"❌ YOLO 模型加载失败: {e}")
@classmethod
def detect(cls, img_bytes: bytes):
if not cls._ready or cls._model is None:
raise RuntimeError("模型未加载或不可用")
label_map = {
"normal": "正茧",
"double_pupa": "双宫茧",
"spot": "黄斑茧",
"hairy": "毛茧",
"maggot_shell": "蛆壳茧",
}
img = cv2.imdecode(np.frombuffer(img_bytes, np.uint8), cv2.IMREAD_COLOR)
results = cls._model(img, conf=0.45)
r = results[0]
boxes = r.boxes
names = cls._model.names
total = len(boxes)
class_counts = {}
confidences = []
box_list = []
label_list = []
for idx, b in enumerate(boxes):
cls_id = int(b.cls)
conf = float(b.conf)
label_en = names.get(cls_id, str(cls_id))
label_cn = label_map.get(label_en, label_en)
class_counts[label_cn] = class_counts.get(label_cn, 0) + 1
confidences.append(conf)
box_list.append(b.xyxy[0])
label_list.append(label_cn)
# 用 PIL 绘制中文
annotated = draw_annotations(img, box_list, label_list, confidences)
_, buffer = cv2.imencode(".jpg", annotated)
img_bytes_out = buffer.tobytes()
result_json = {
"total_objects": total,
"class_counts": class_counts,
"min_confidence": MyUtils.safe_round(min(confidences), 4),
"max_confidence": MyUtils.safe_round(max(confidences), 4),
"avg_confidence": (
MyUtils.safe_round(sum(confidences) / len(confidences), 4)
),
"speed_ms": r.speed, # 直接来自 YOLO
}
return img_bytes_out, result_json
+118
View File
@@ -635,3 +635,121 @@ def get_license_image_list(user_id, page=1, page_size=10):
)
return total, result
# ————————————————————————————————————————————————————蚕茧质量识别———————————————————————————————
def insert_sca_image(
file_name,
resolution,
size,
cocoon_count,
max_confidence,
min_confidence,
average_confidence,
other_info,
preprocess_time_ms,
inference_time_ms,
postprocess_time_ms,
name,
image_pre,
image_after,
created_by,
):
with pg_pool.getConn() as conn:
with conn.cursor() as cursor:
other_info = json.dumps(other_info)
cursor.execute(
"""
INSERT INTO sca_images (
upload_datetime, file_name, resolution, size, cocoon_count, max_confidence, min_confidence,
average_confidence, other_info, preprocess_time_ms, inference_time_ms, postprocess_time_ms, name, image_pre, image_after, created_by
)
VALUES (NOW(), %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s )
RETURNING id
""",
(
file_name,
resolution,
size,
cocoon_count,
max_confidence,
min_confidence,
average_confidence,
other_info,
preprocess_time_ms,
inference_time_ms,
postprocess_time_ms,
name,
image_pre,
image_after,
created_by,
),
)
new_id = cursor.fetchone()[0]
conn.commit()
return new_id
def get_sca_image_list(user_id, name, page=1, page_size=10):
"""
获取用户已分析图片列表,带分页
"""
offset = (page - 1) * page_size
with pg_pool.getConn() as conn:
with conn.cursor() as cursor:
# 1️⃣ 查询总条数
# ✅ 改进版:支持 name 为空时统计全部,不为空时模糊统计
cursor.execute(
"""
SELECT COUNT(*)
FROM sca_images
WHERE created_by = %s
AND (%s = '' OR name LIKE '%%' || %s || '%%')
""",
(user_id, name, name),
)
total = cursor.fetchone()[0]
# 2️⃣ 查询当前页数据
# ✅ 改进版
cursor.execute(
"""
SELECT id, name, upload_datetime, file_name, image_pre, image_after, resolution,
size, cocoon_count, max_confidence, min_confidence, average_confidence, other_info, preprocess_time_ms, inference_time_ms, postprocess_time_ms
FROM sca_images
WHERE created_by = %s
AND (%s = '' OR name LIKE '%%' || %s || '%%')
ORDER BY upload_datetime DESC
LIMIT %s OFFSET %s
""",
(user_id, name, name, page_size, offset),
)
rows = cursor.fetchall()
result = []
for row in rows:
result.append(
{
"id": row[0],
"name": row[1],
"upload_datetime": MyUtils.format_datetime(row[2]),
"file_name": row[3],
"image_pre": get_temp_url("image-sca", "raw/" + row[4]),
"image_after": get_temp_url("image-sca", "ai/" + row[5]),
"resolution": row[6],
"size": MyUtils.safe_round(row[7] / 1024, 2),
"cocoon_count": row[8],
"max_confidence": row[9],
"min_confidence": row[10],
"average_confidence": row[11],
"other_info": row[12],
"preprocess_time_ms": MyUtils.safe_round(row[13], 4),
"inference_time_ms": MyUtils.safe_round(row[14], 4),
"postprocess_time_ms": MyUtils.safe_round(row[15], 4),
}
)
return total, result
+2
View File
@@ -21,6 +21,8 @@ pyzxing==1.1.1
Pillow==11.3.0
python-multipart==0.0.20
aio_pika==9.5.7
ultralytics==8.3.227
# MCP服务
python-dotenv>=1.0.0
websockets>=11.0.3
+24 -1
View File
@@ -6,7 +6,11 @@ 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, process_license_image
from service.vision import (
process_ticket_image,
process_license_image,
process_silkworm_cocoon_image,
)
from utils import MyUtils
publicRouter = APIRouter()
@@ -55,3 +59,22 @@ async def cocoon_license(data: F8ImageRequest):
return BaseResponse(data=data)
except Exception as e:
return BaseResponse(status=False, message=f"解析失败: {str(e)}", data=None)
@publicRouter.post("/recognize-silkworm-cocoon")
async def recognize_silkworm_cocoon(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_silkworm_cocoon_image,
img_bytes,
f"{data.title}.jpg",
data.title,
F8_SERVER_USER_ID,
)
return BaseResponse(data=json_data)
except Exception as e:
return BaseResponse(status=False, message=f"解析失败: {str(e)}", data=None)
+51 -23
View File
@@ -7,7 +7,11 @@ 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, process_license_image
from service.vision import (
process_ticket_image,
process_license_image,
process_silkworm_cocoon_image,
)
from utils import MyUtils
visionRouter = APIRouter()
@@ -25,6 +29,8 @@ def cocoonTicket(data: ImageRequest, user_id: UUID = Depends(get_user_id_from_to
return BaseResponse(status=False, message="unknown error", data=None)
# ————————————————————————————————仪评指标联识别任务————————————————————————————————————————————————
# 一代
@visionRouter.post("/createTicketImageTask")
async def createTicketImageTask(
file: UploadFile = File(...),
@@ -44,13 +50,7 @@ async def createTicketImageTask(
return BaseResponse(status=False, message=f"解析失败: {str(e)}", data=None)
@visionRouter.get("/getTicketImageList")
def cocoonTicket(user_id: UUID = Depends(get_user_id_from_token)):
if not user_id:
return {"error": "userId is required"}
return BaseResponse(data=pg.get_ticket_image_list(user_id))
# 二代
@visionRouter.post("/createTicketImageTaskV2")
async def createTicketImageTask(
file: UploadFile = File(...),
@@ -70,24 +70,15 @@ async def createTicketImageTask(
return BaseResponse(status=False, message=f"解析失败: {str(e)}", data=None)
@visionRouter.post("/createTicketImageTaskV2")
async def createTicketImageTask(
file: UploadFile = File(...),
projectName: str = Form(...),
user_id: UUID = Depends(get_user_id_from_token),
):
# 获取仪评指标联识别任务列表
@visionRouter.get("/getTicketImageList")
def cocoonTicket(user_id: UUID = Depends(get_user_id_from_token)):
if not user_id:
return {"error": "userId is required"}
try:
contents = await file.read()
json_data = await MyUtils.async_task(
process_ticket_image, 2, True, contents, file.filename, projectName, user_id
)
return BaseResponse(data=json_data)
except Exception as e:
return BaseResponse(status=False, message=f"解析失败: {str(e)}", data=None)
return BaseResponse(data=pg.get_ticket_image_list(user_id))
# ————————————————————————————————证件照片识别任务————————————————————————————————————————————————
@visionRouter.post("/createLicenseImageTask")
async def createLicenseImageTask(
file: UploadFile = File(...),
@@ -107,7 +98,7 @@ async def createLicenseImageTask(
@visionRouter.get("/getLicenseImageList")
def cocoonLicense(
def getLicenseImageList(
user_id: UUID = Depends(get_user_id_from_token),
page: int = Query(1, ge=1),
page_size: int = Query(10, ge=1, le=100),
@@ -121,3 +112,40 @@ def cocoonLicense(
"items": items,
}
)
# ————————————————————————————————蚕茧识别任务————————————————————————————————————————————————
@visionRouter.post("/createSilkwormCocoonAnalysisTask")
async def createSilkwormCocoonAnalysisTask(
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_silkworm_cocoon_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("/getSilkwormCocoonAnalysisTasks")
def getSilkwormCocoonAnalysisTasks(
user_id: UUID = Depends(get_user_id_from_token),
name: str = "",
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_sca_image_list(user_id, name, page=page, page_size=page_size)
return BaseResponse(
data={
"total": total,
"items": items,
}
)
+84
View File
@@ -5,6 +5,7 @@ import config.minIO as minIO
import db.postgres as pg
from agent.licenseImageAgent import get_license_response
from config.minIO import minio_client
from config.yolo import YOLOSingleton
from llm.ticketLLM import *
from llm.ticketLLMv2 import get_ticket_response_v2
@@ -106,3 +107,86 @@ def process_license_image(
)
return json_data
def process_silkworm_cocoon_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 = []
pre_object_name = str(uuid.uuid4())
after_object_name = str(uuid.uuid4())
file_bytes = BytesIO(img_bytes)
bucket_name = "image-sca"
if not minio_client.bucket_exists(bucket_name):
minio_client.make_bucket(bucket_name)
minIO.push_file(
bucket_name, "raw/" + pre_object_name, file_bytes, img_bytes, "image/jpeg"
)
# YOLO检测
img_bytes_out, results_json = YOLOSingleton.detect(img_bytes)
# results_json = {
# "total_objects": "",
# "max_confidence": "",
# "min_confidence": "",
# "avg_confidence": "",
# "class_counts": "",
# "speed_ms": {
# "preprocess": "",
# "inference": "",
# "postprocess": "",
# },
# }
speed_json = results_json.get("speed_ms")
file_bytes_out = BytesIO(img_bytes_out)
minIO.push_file(
bucket_name,
"ai/" + after_object_name,
file_bytes_out,
img_bytes_out,
"image/jpeg",
)
# 获取图片分辨率和大小
img = Image.open(BytesIO(img_bytes))
resolution = f"{img.width}x{img.height}"
size_kb = round(len(img_bytes) / 1024, 2)
# 插入数据库
pg.insert_sca_image(
file_name=file_name,
resolution=resolution,
size=size_kb,
cocoon_count=results_json.get("total_objects"),
max_confidence=results_json.get("max_confidence"),
min_confidence=results_json.get("min_confidence"),
average_confidence=results_json.get("avg_confidence"),
other_info=results_json.get("class_counts"),
preprocess_time_ms=speed_json.get("preprocess"),
inference_time_ms=speed_json.get("inference"),
postprocess_time_ms=speed_json.get("postprocess"),
name=project_name if project_name else pre_object_name[:8],
image_pre=pre_object_name,
image_after=after_object_name,
created_by=user_id,
)
return {
"resolution": resolution,
"size": size_kb,
"cocoon_count": results_json.get("total_objects"),
"max_confidence": results_json.get("max_confidence"),
"min_confidence": results_json.get("min_confidence"),
"average_confidence": results_json.get("avg_confidence"),
"preprocess_time_ms": speed_json.get("preprocess"),
"inference_time_ms": speed_json.get("inference"),
"postprocess_time_ms": speed_json.get("postprocess"),
"details": results_json.get("class_counts"),
}
+1
View File
@@ -0,0 +1 @@
LOCAL_IP = "10.10.12.101"
+4
View File
@@ -20,3 +20,7 @@ def format_datetime(dt: datetime, tz="Asia/Shanghai"):
tz_obj = pytz.timezone(tz)
dt = dt.astimezone(tz_obj)
return dt.strftime("%Y-%m-%d %H:%M:%S")
def safe_round(value, ndigits=2, default=None):
return round(value, ndigits) if value is not None else default