diff --git a/inventory-backend/app/models/base.py b/inventory-backend/app/models/base.py index e96de80..37e3674 100644 --- a/inventory-backend/app/models/base.py +++ b/inventory-backend/app/models/base.py @@ -1,5 +1,6 @@ # app/models/base.py from app.extensions import db +from pgvector.sqlalchemy import Vector import json @@ -34,6 +35,9 @@ class MaterialBase(db.Model): # 强制质检标记(采购入库时必须上传检测报告) is_inspection_required = db.Column(db.Boolean, default=False, comment='是否强制要求质检') + # CLIP 视觉向量(用于以图搜图) + img_embedding = db.Column(Vector(512), nullable=True) + # ============================================================ # 关联关系区域 # ============================================================ diff --git a/inventory-backend/app/models/inbound/buy.py b/inventory-backend/app/models/inbound/buy.py index e323937..3e0d7fd 100644 --- a/inventory-backend/app/models/inbound/buy.py +++ b/inventory-backend/app/models/inbound/buy.py @@ -1,5 +1,6 @@ # inventory-backend/app/models/inbound/buy.py from app.extensions import db +from pgvector.sqlalchemy import Vector import json # 显式导入 MaterialBase 以防 relationship 找不到引用 from app.models.base import MaterialBase @@ -55,6 +56,9 @@ class StockBuy(db.Model): # 全局打印流水号 global_print_id = db.Column(db.Integer) + # CLIP 视觉向量(用于以图搜图) + arrival_image_embedding = db.Column(Vector(512), nullable=True) + # 关系定义 base = db.relationship('MaterialBase', back_populates='stock_buys') diff --git a/inventory-backend/app/models/inbound/product.py b/inventory-backend/app/models/inbound/product.py index bc77ee9..ef38d33 100644 --- a/inventory-backend/app/models/inbound/product.py +++ b/inventory-backend/app/models/inbound/product.py @@ -1,5 +1,6 @@ # app/models/inbound/product.py from app.extensions import db +from pgvector.sqlalchemy import Vector import json from app.models.base import MaterialBase @@ -58,6 +59,9 @@ class StockProduct(db.Model): # 全局打印流水号 global_print_id = db.Column(db.Integer) + # CLIP 视觉向量(用于以图搜图) + arrival_image_embedding = db.Column(Vector(512), nullable=True) + # 关系定义 base = db.relationship('MaterialBase', back_populates='stock_products') diff --git a/inventory-backend/app/models/inbound/semi.py b/inventory-backend/app/models/inbound/semi.py index c1942dd..71f34af 100644 --- a/inventory-backend/app/models/inbound/semi.py +++ b/inventory-backend/app/models/inbound/semi.py @@ -1,5 +1,6 @@ # app/models/inbound/semi.py from app.extensions import db +from pgvector.sqlalchemy import Vector import json from app.models.base import MaterialBase @@ -56,6 +57,9 @@ class StockSemi(db.Model): # 全局打印流水号 global_print_id = db.Column(db.Integer) + # CLIP 视觉向量(用于以图搜图) + arrival_image_embedding = db.Column(Vector(512), nullable=True) + # 关系定义 base = db.relationship('MaterialBase', back_populates='stock_semis') diff --git a/inventory-backend/app/services/inbound/base_service.py b/inventory-backend/app/services/inbound/base_service.py index 120c857..d40f7cb 100644 --- a/inventory-backend/app/services/inbound/base_service.py +++ b/inventory-backend/app/services/inbound/base_service.py @@ -12,6 +12,7 @@ import traceback import json import io import datetime +from app.utils.ai_vision import extract_and_embed # 需要 pip install openpyxl from openpyxl import Workbook from openpyxl.styles import Font, Alignment, Border, Side, PatternFill @@ -555,7 +556,9 @@ class MaterialBaseService: product_image=json.dumps(data.get('generalImage', [])), is_enabled=is_enabled_val ) - + # 实时提取产品图向量(失败不影响业务) + if new_material.product_image: + new_material.img_embedding = extract_and_embed(new_material.product_image) db.session.add(new_material) db.session.commit() return new_material @@ -587,6 +590,10 @@ class MaterialBaseService: if 'generalImage' in data: material.product_image = json.dumps(data['generalImage']) + # 补上这两行:提取新上传图片的向量! + if material.product_image: + material.img_embedding = extract_and_embed(material.product_image) + # 【核心修改】:兼容前端传来的布尔值 if 'isEnabled' in data: raw_enabled = data['isEnabled'] diff --git a/inventory-backend/app/services/inbound/buy_service.py b/inventory-backend/app/services/inbound/buy_service.py index f210e03..303f3c4 100644 --- a/inventory-backend/app/services/inbound/buy_service.py +++ b/inventory-backend/app/services/inbound/buy_service.py @@ -9,6 +9,7 @@ from sqlalchemy import or_, func, text, and_ from sqlalchemy.exc import IntegrityError import traceback import json +from app.utils.ai_vision import extract_and_embed class BuyInboundService: @@ -177,6 +178,9 @@ class BuyInboundService: arrival_photo=json.dumps(data.get('arrival_photo', [])), inspection_report=json.dumps(data.get('inspection_report', [])) ) + # 实时提取到货图片向量(失败不影响业务) + if new_stock.arrival_photo: + new_stock.arrival_image_embedding = extract_and_embed(new_stock.arrival_photo) db.session.add(new_stock) db.session.commit() return new_stock diff --git a/inventory-backend/app/services/inbound/product_service.py b/inventory-backend/app/services/inbound/product_service.py index de57d4f..6005b67 100644 --- a/inventory-backend/app/services/inbound/product_service.py +++ b/inventory-backend/app/services/inbound/product_service.py @@ -9,6 +9,7 @@ from sqlalchemy import or_, func, text, and_ from sqlalchemy.exc import IntegrityError import traceback import json +from app.utils.ai_vision import extract_and_embed class ProductInboundService: @@ -183,6 +184,9 @@ class ProductInboundService: sale_price=float(data.get('sale_price') or 0), order_id=data.get('order_id') ) + # 实时提取成品实拍图向量(失败不影响业务) + if new_stock.product_photo: + new_stock.arrival_image_embedding = extract_and_embed(new_stock.product_photo) db.session.add(new_stock) db.session.commit() return new_stock diff --git a/inventory-backend/app/services/inbound/semi_service.py b/inventory-backend/app/services/inbound/semi_service.py index 12dfc27..41fe240 100644 --- a/inventory-backend/app/services/inbound/semi_service.py +++ b/inventory-backend/app/services/inbound/semi_service.py @@ -9,6 +9,7 @@ from sqlalchemy import or_, func, text, and_ from sqlalchemy.exc import IntegrityError import traceback import json +from app.utils.ai_vision import extract_and_embed class SemiInboundService: @@ -220,6 +221,9 @@ class SemiInboundService: detail_link=data.get('detail_link'), remark=data.get('remark') ) + # 实时提取到货图片向量(失败不影响业务) + if new_stock.arrival_photo: + new_stock.arrival_image_embedding = extract_and_embed(new_stock.arrival_photo) db.session.add(new_stock) db.session.commit() return new_stock diff --git a/inventory-backend/app/utils/ai_vision.py b/inventory-backend/app/utils/ai_vision.py index 353f1da..8a0fd06 100644 --- a/inventory-backend/app/utils/ai_vision.py +++ b/inventory-backend/app/utils/ai_vision.py @@ -4,6 +4,8 @@ AI Vision 模块 - CLIP Vision Encoder ONNX 推理 """ import os +import json +import time import numpy as np from PIL import Image import onnxruntime as ort @@ -129,4 +131,62 @@ def get_image_embedding(image_path: str) -> list: 'attention_mask': dummy_mask } ) - return outputs[0][0].tolist() \ No newline at end of file + return outputs[0][0].tolist() + + +# ============================================================================ +# 通用向量提取工具:防呆、防错 +# ============================================================================ + +def extract_and_embed(photo_source): + if not photo_source: + return None + try: + # 1. 提取基础字符串 + photo_source_str = str(photo_source).strip() + raw_path = "" + + # 尝试剥掉 JSON 外壳 + try: + parsed = json.loads(photo_source_str) + if isinstance(parsed, list): + raw_path = parsed[0] if parsed else "" + elif isinstance(parsed, str): + raw_path = parsed + else: + raw_path = str(parsed) + except: + raw_path = photo_source_str + + if not raw_path: + return None + + # 2. 剥离出最纯净的文件名 (只取最后一段) + pure_filename = raw_path.split('/')[-1] + + # 3. 【终极物理净化】强行抠掉所有多余的标点符号! + # 哪怕传进来的是 123.jpg"] 或者是 "123.jpg",全部洗干净 + pure_filename = pure_filename.replace('"', '').replace("'", "").replace('[', '').replace(']', '') + + # 4. 拼接真实的 Docker 物理路径 + file_path = os.path.join('/app/uploads', pure_filename) + + # 5. 加入重试机制 (最多等 3 秒) + max_retries = 6 + for i in range(max_retries): + if os.path.exists(file_path): + # 文件找到了,开始提取向量 + vec = get_image_embedding(file_path) + if isinstance(vec, np.ndarray): + return vec.tolist() + return vec + else: + print(f"[AI 识图等待] 第 {i+1} 次尝试,未找到文件 {file_path},等待 0.5s...") + time.sleep(0.5) + + print(f"[AI 识图警告] 彻底失败!经过等待依然未找到图片: {file_path}") + + except Exception as e: + print(f"[AI 识图错误] 实时提取向量失败: {str(e)}") + + return None diff --git a/inventory-backend/requirements.txt b/inventory-backend/requirements.txt index 7404dee..a261704 100644 --- a/inventory-backend/requirements.txt +++ b/inventory-backend/requirements.txt @@ -27,4 +27,6 @@ APScheduler==3.10.4 # [新增] 时区处理 (APScheduler 需要) pytz # [新增] 进度条库 (脚本和任务所需) -tqdm>=4.66.0 \ No newline at end of file +tqdm>=4.66.0 +# [新增] pgvector 向量数据库支持(以图搜图 / 实时向量提取) +pgvector>=0.2.0 \ No newline at end of file