Ver Fonte

新增2个结果图字段

AnlaAnla há 1 mês atrás
pai
commit
38059d2d3d

+ 1 - 0
.idea/encodings.xml

@@ -2,6 +2,7 @@
 <project version="4">
   <component name="Encoding">
     <file url="file://$PROJECT_DIR$/Test/img_data_insert.py" charset="UTF-8" />
+    <file url="file://$PROJECT_DIR$/Test/img_score_and_insert.py" charset="UTF-8" />
     <file url="file://$PROJECT_DIR$/Test/test01.py" charset="GBK" />
     <file url="file://$PROJECT_DIR$/app/api/cards.py" charset="UTF-8" />
     <file url="file://$PROJECT_DIR$/app/api/images.py" charset="UTF-8" />

+ 233 - 0
Test/img_score_and_insert.py

@@ -0,0 +1,233 @@
+import asyncio
+import aiohttp
+import aiofiles
+import json
+import os
+from typing import Dict, Any, Tuple
+from datetime import datetime
+
+# --- 配置区域 ---
+# 1. 服务 URL
+INFERENCE_SERVICE_URL = "http://192.168.31.243:7744"
+STORAGE_SERVICE_URL = "http://192.168.31.243:7745"
+
+# 2. 要处理的卡片信息
+formate_time = datetime.now().strftime("%Y-%m-%d_%H:%M")
+CARD_NAME = f"卡 {formate_time}"
+
+# 3. 四张卡片图片的本地路径
+front_face_img_path = r"C:\Code\ML\Image\Card\_250915_many_capture_img\_250919_1500_no_reflect_nature_defect\15_front_coaxial_1_0.jpg"
+front_edge_img_path = r"C:\Code\ML\Image\Card\_250915_many_capture_img\_250919_1500_no_reflect_nature_defect\15_front_ring_0_1.jpg"
+back_face_img_path = r"C:\Code\ML\Image\Card\_250915_many_capture_img\_250919_1500_no_reflect_nature_defect\15_back_coaxial_1_0.jpg"
+back_edge_img_path = r"C:\Code\ML\Image\Card\_250915_many_capture_img\_250919_1500_no_reflect_nature_defect\15_back_ring_0_1.jpg"
+IMAGE_PATHS = [
+    front_edge_img_path,
+    front_face_img_path,
+    back_edge_img_path,
+    back_face_img_path
+]
+
+# 4. 推理服务需要的 score_type 参数
+SCORE_TYPES = [
+    "front_corner_edge",
+    "front_face",
+    "back_corner_edge",
+    "back_face"
+]
+
+SCORE_TO_IMAGE_TYPE_MAP = {
+    "front_corner_edge": "front_edge",
+    "front_face": "front_face",
+    "back_corner_edge": "back_edge",
+    "back_face": "back_face"
+}
+
+
+# --- 脚本主逻辑 ---
+
+async def call_api_with_file(
+        session: aiohttp.ClientSession,
+        url: str,
+        file_path: str,
+        params: Dict[str, Any] = None,
+        form_fields: Dict[str, Any] = None
+) -> Tuple[int, bytes]:
+    """通用的文件上传API调用函数 (从文件路径读取)"""
+    form_data = aiohttp.FormData()
+
+    if form_fields:
+        for key, value in form_fields.items():
+            form_data.add_field(key, str(value))
+
+    async with aiofiles.open(file_path, 'rb') as f:
+        content = await f.read()
+        form_data.add_field(
+            'file',
+            content,
+            filename=os.path.basename(file_path),
+            content_type='image/jpeg'
+        )
+
+    try:
+        async with session.post(url, data=form_data, params=params) as response:
+            response_content = await response.read()
+            if not response.ok:
+                print(f"错误: 调用 {url} 失败, 状态码: {response.status}")
+                print(f"  错误详情: {response_content.decode(errors='ignore')}")
+            return response.status, response_content
+    except aiohttp.ClientConnectorError as e:
+        print(f"错误: 无法连接到服务 {url} - {e}")
+        return 503, b"Connection Error"
+
+
+async def process_single_image(
+        session: aiohttp.ClientSession,
+        image_path: str,
+        score_type: str
+) -> Dict[str, Any]:
+    """处理单张图片:获取转正图和分数JSON"""
+    print(f"  正在处理图片: {image_path} (类型: {score_type})")
+
+    # 1. 获取转正后的图片
+    rectify_url = f"{INFERENCE_SERVICE_URL}/api/card_inference/card_rectify_and_center"
+    rectify_status, rectified_image_bytes = await call_api_with_file(
+        session, url=rectify_url, file_path=image_path
+    )
+    if rectify_status >= 300:
+        raise Exception(f"获取转正图失败: {image_path}")
+    print(f"    -> 已成功获取转正图")
+
+    # 2. 获取分数JSON
+    score_url = f"{INFERENCE_SERVICE_URL}/api/card_score/score_inference"
+    score_params = {
+        "score_type": score_type,
+        "is_reflect_card": "false"
+    }
+    score_status, score_json_bytes = await call_api_with_file(
+        session,
+        url=score_url,
+        file_path=image_path,
+        params=score_params
+    )
+    if score_status >= 300:
+        raise Exception(f"获取分数JSON失败: {image_path}")
+
+    score_json = json.loads(score_json_bytes)
+    print(f"    -> 已成功获取分数JSON")
+
+    return {
+        "score_type": score_type,
+        "rectified_image": rectified_image_bytes,
+        "score_json": score_json
+    }
+
+
+async def create_card_set(session: aiohttp.ClientSession, card_name: str) -> int:
+    """创建一个新的卡组并返回其ID"""
+    url = f"{STORAGE_SERVICE_URL}/api/cards/created"
+    params = {'card_name': card_name}
+    print(f"\n[步骤 2] 正在创建卡组,名称: '{card_name}'...")
+    try:
+        async with session.post(url, params=params) as response:
+            if response.ok:
+                data = await response.json()
+                card_id = data.get('id')
+                if card_id is not None:
+                    print(f"  -> 成功创建卡组, ID: {card_id}")
+                    return card_id
+                else:
+                    raise Exception("创建卡组API的响应中未找到'id'字段")
+            else:
+                error_text = await response.text()
+                raise Exception(f"创建卡组失败, 状态码: {response.status}, 详情: {error_text}")
+    except aiohttp.ClientConnectorError as e:
+        raise Exception(f"无法连接到存储服务 {url} - {e}")
+
+
+# 【修改点】: 修正此函数
+async def upload_processed_data(
+        session: aiohttp.ClientSession,
+        card_id: int,
+        processed_data: Dict[str, Any]
+):
+    """上传单张转正图和对应的JSON到存储服务"""
+    score_type = processed_data['score_type']
+    image_type_for_storage = SCORE_TO_IMAGE_TYPE_MAP[score_type]
+
+    print(f"  正在上传图片, 类型: {image_type_for_storage}...")
+
+    url = f"{STORAGE_SERVICE_URL}/api/images/insert/{card_id}"
+
+    # 直接构建FormData,因为图片数据已经在内存中 (processed_data['rectified_image'])
+    form_data = aiohttp.FormData()
+    form_data.add_field('image_type', image_type_for_storage)
+    form_data.add_field('json_data_str', json.dumps(processed_data['score_json'], ensure_ascii=False))
+    form_data.add_field(
+        'image',
+        processed_data['rectified_image'],
+        filename='rectified.jpg',
+        content_type='image/jpeg'
+    )
+
+    try:
+        async with session.post(url, data=form_data) as response:
+            if response.status == 201:
+                print(f"    -> 成功上传并关联图片: {image_type_for_storage}")
+            else:
+                error_text = await response.text()
+                print(
+                    f"    -> 错误: 上传失败! 类型: {image_type_for_storage}, 状态码: {response.status}, 详情: {error_text}")
+    except aiohttp.ClientConnectorError as e:
+        print(f"    -> 错误: 无法连接到存储服务 {url} - {e}")
+
+
+async def main():
+    """主执行函数"""
+    async with aiohttp.ClientSession() as session:
+        # 步骤 1: 并发处理所有图片, 获取转正图和分数
+        print("[步骤 1] 开始并发处理所有图片...")
+        process_tasks = []
+        for path, s_type in zip(IMAGE_PATHS, SCORE_TYPES):
+            if not os.path.exists(path):
+                print(f"错误:文件不存在,请检查路径配置: {path}")
+                return
+            task = asyncio.create_task(process_single_image(session, path, s_type))
+            process_tasks.append(task)
+
+        try:
+            processed_results = await asyncio.gather(*process_tasks)
+            print("  -> 所有图片处理完成!")
+        except Exception as e:
+            print(f"\n在处理图片过程中发生错误: {e}")
+            return
+
+        # 步骤 2: 创建卡组
+        try:
+            card_id = await create_card_set(session, CARD_NAME)
+        except Exception as e:
+            print(f"\n创建卡组时发生严重错误: {e}")
+            return
+
+        # 步骤 3: 并发上传所有处理好的数据
+        print(f"\n[步骤 3] 开始为卡组ID {card_id} 并发上传图片和数据...")
+        upload_tasks = []
+        for result in processed_results:
+            task = asyncio.create_task(upload_processed_data(session, card_id, result))
+            upload_tasks.append(task)
+
+        await asyncio.gather(*upload_tasks)
+        print("  -> 所有数据上传完成!")
+
+    print("\n====================")
+    print("所有流程执行完毕!")
+    print("====================")
+
+
+if __name__ == "__main__":
+    if len(IMAGE_PATHS) != 4 or len(SCORE_TYPES) != 4:
+        print("错误: IMAGE_PATHS 和 SCORE_TYPES 列表的长度必须为4,请检查配置。")
+    else:
+        # 在 Windows 上使用 ProactorEventLoop 可能会更稳定
+        if os.name == 'nt':
+            asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
+        asyncio.run(main())

+ 1 - 1
Test/test01.py

@@ -34,5 +34,5 @@ def send(url):
 
 if __name__ == '__main__':
     # base_url = 'http://127.0.0.1:7745/api/cards/query/9'
-    base_url = 'http://192.168.31.243:7745/api/cards/query/3'
+    base_url = 'http://192.168.31.243:7745/api/cards/query/6'
     send(base_url)

+ 8 - 3
app/api/cards.py

@@ -136,10 +136,15 @@ def delete_card(id: int, db_conn: PooledMySQLConnection = db_dependency):
     try:
         cursor = db_conn.cursor()
 
-        # 1. 查询所有关联图片的物理文件路径,以便稍后删除
-        query_paths = f"SELECT image_path FROM {settings.DB_IMAGE_TABLE_NAME} WHERE card_id = %s"
+        # 1. 查询所有关联图片的所有物理文件路径,以便稍后删除 (修改部分)
+        query_paths = (f"SELECT image_path, detection_image_path, modified_image_path "
+                       f"FROM {settings.DB_IMAGE_TABLE_NAME} WHERE card_id = %s")
         cursor.execute(query_paths, (id,))
-        image_paths_to_delete = [row[0] for row in cursor.fetchall()]
+
+        image_paths_to_delete = []
+        for row in cursor.fetchall():
+            # 将每一行中非空的路径都添加到待删除列表
+            image_paths_to_delete.extend([path for path in row if path])
 
         # 2. 删除卡牌记录。数据库会自动级联删除 card_images 表中的相关记录
         query_delete_card = f"DELETE FROM {settings.DB_CARD_TABLE_NAME} WHERE id = %s"

+ 82 - 3
app/api/images.py

@@ -11,7 +11,7 @@ from mysql.connector import IntegrityError
 
 from app.core.config import settings
 from app.core.logger import get_logger
-from app.utils.scheme import CardImageResponse, ImageJsonPairResponse
+from app.utils.scheme import CardImageResponse, ImageJsonPairResponse, ResultImagePathType
 from app.utils.scheme import ImageType, IMAGE_TYPE_TO_SCORE_TYPE
 from app.core.database_loader import get_db_connection
 
@@ -19,6 +19,7 @@ logger = get_logger(__name__)
 router = APIRouter()
 db_dependency = Depends(get_db_connection)
 
+
 @router.post("/insert/{card_id}", response_model=CardImageResponse, status_code=201,
              summary="为卡牌上传并关联一张图片")
 async def upload_image_for_card(
@@ -156,7 +157,7 @@ async def update_image_modified_json(
                     settings.SCORE_RECALCULATE_ENDPOINT,
                     params=params,
                     json=payload,
-                    timeout=30
+                    timeout=20
                 )
             )
         except Exception as e:
@@ -174,8 +175,10 @@ async def update_image_modified_json(
         update_query = f"UPDATE {settings.DB_IMAGE_TABLE_NAME} SET modified_json = %s WHERE id = %s"
         cursor.execute(update_query, (recalculated_json_str, id))
 
+        logger.info(f"更新 id={id} 的操作影响了 {cursor.rowcount} 行。")
+
         if cursor.rowcount == 0:
-            raise HTTPException(status_code=404, detail=f"未找到ID {id} 的记录。")
+            raise HTTPException(status_code=500, detail=f"更新失败,ID为 {id} 的记录未找到或数据未发生变化。")
 
         db_conn.commit()
         logger.info(f"图片ID {id} 的 modified_json 已更新并重新计算。")
@@ -197,6 +200,82 @@ async def update_image_modified_json(
         if cursor:
             cursor.close()
 
+
+@router.put("/update/annotated/{id}", status_code=200, summary="为指定图片记录上传绘制后的结果图")
+async def upload_result_image(
+        id: int = Path(..., description="要更新的图片记录ID"),
+        path_type: ResultImagePathType = Form(...,
+                                              description="要更新的路径类型 ('detection' 或 'modified')"),
+        image: UploadFile = File(..., description="结果图片文件"),
+        db_conn: PooledMySQLConnection = db_dependency
+):
+    """
+    为一条现有的 card_images 记录上传 detection_image_path 或 modified_image_path 对应的图片。
+    如果该字段已有图片,旧的图片文件将被删除。
+    """
+    # 1. 保存新图片文件
+    file_extension = os.path.splitext(image.filename)[1]
+    unique_filename = f"{uuid.uuid4()}{file_extension}"
+    new_image_path = settings.DATA_DIR / unique_filename
+    relative_path = f"/{new_image_path.parent.name}/{new_image_path.name}"
+    try:
+        with open(new_image_path, "wb") as buffer:
+            buffer.write(await image.read())
+    except Exception as e:
+        logger.error(f"保存结果图片失败: {e}")
+        raise HTTPException(status_code=500, detail="无法保存图片文件。")
+
+    cursor = None
+    try:
+        cursor = db_conn.cursor(dictionary=True)
+
+        # 2. 确定要更新的字段名
+        column_to_update = f"{path_type.value}_image_path"
+
+        # 3. 查询旧的图片路径,以便稍后删除
+        cursor.execute(f"SELECT {column_to_update} FROM {settings.DB_IMAGE_TABLE_NAME} WHERE id = %s", (id,))
+        record = cursor.fetchone()
+        if not record:
+            os.remove(new_image_path)  # 如果记录不存在,删除刚上传的文件
+            raise HTTPException(status_code=404, detail=f"ID为 {id} 的图片记录未找到。")
+
+        old_path = record.get(column_to_update)
+
+        # 4. 更新数据库
+        update_query = f"UPDATE {settings.DB_IMAGE_TABLE_NAME} SET {column_to_update} = %s WHERE id = %s"
+        cursor.execute(update_query, (relative_path, id))
+        db_conn.commit()
+        logger.info(f"图片记录 {id} 的 {column_to_update} 已更新为 {relative_path}")
+
+        # 5. 删除旧的物理文件(如果存在)
+        if old_path:
+            absolute_old_path = settings.BASE_PATH / old_path.lstrip('/\\')
+            if os.path.exists(absolute_old_path):
+                try:
+                    os.remove(absolute_old_path)
+                    logger.info(f"旧的结果图片文件已删除: {absolute_old_path}")
+                except OSError as e:
+                    logger.error(f"删除旧文件失败 {absolute_old_path}: {e}")
+
+        # 6. 返回更新后的路径
+        return {
+            "message": f"成功更新图片ID {id} 的结果图",
+            column_to_update: relative_path
+        }
+
+    except Exception as e:
+        db_conn.rollback()
+        # 如果发生任何错误,都要删除刚刚上传的新文件
+        if os.path.exists(new_image_path):
+            os.remove(new_image_path)
+        logger.error(f"更新结果图片路径失败 (id={id}): {e}")
+        if isinstance(e, HTTPException): raise e
+        raise HTTPException(status_code=500, detail="数据库操作失败,更改已回滚。")
+    finally:
+        if cursor:
+            cursor.close()
+
+
 @router.get("/image_file/{id}", summary="获取指定ID的图片文件")
 def get_image_file(id: int, db_conn: PooledMySQLConnection = db_dependency):
     """根据 id 查找记录,并返回对应的图片文件。"""

+ 3 - 2
app/core/database_loader.py

@@ -44,11 +44,12 @@ def init_database():
             "  `image_type` VARCHAR(50) NOT NULL COMMENT '图片类型 (front_face, back_face, etc)',"
             "  `image_name` VARCHAR(255) NULL,"
             "  `image_path` VARCHAR(512) NOT NULL,"
+            "  `detection_image_path` VARCHAR(512) NULL COMMENT '检测结果图的路径',"
+            "  `modified_image_path` VARCHAR(512) NULL COMMENT '修改后结果图的路径',"
             "  `detection_json` JSON NOT NULL COMMENT '原始检测JSON数据',"
             "  `modified_json` JSON NULL COMMENT '修改后的JSON数据',"
             "  `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,"
             "  `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,"
-            # 外键现在引用 cards(id)
             f"  FOREIGN KEY (`card_id`) REFERENCES `{settings.DB_CARD_TABLE_NAME}`(`id`) ON DELETE CASCADE,"
             "  UNIQUE KEY `uk_card_image_type` (`card_id`, `image_type`)"
             ") ENGINE=InnoDB COMMENT='存储所有卡牌的图片及检测数据'"
@@ -112,4 +113,4 @@ def get_db_connection():
         logger.error(f"获取数据库连接失败: {err}")
     finally:
         if db_conn and db_conn.is_connected():
-            db_conn.close()
+            db_conn.close()

+ 9 - 1
app/utils/scheme.py

@@ -13,6 +13,12 @@ class ImageType(str, Enum):
     back_edge = "back_edge"
 
 
+# 新增:用于指定要更新哪个结果图片路径的枚举
+class ResultImagePathType(str, Enum):
+    detection = "detection"
+    modified = "modified"
+
+
 # "图片类型和计算分数分数类型映射表"
 IMAGE_TYPE_TO_SCORE_TYPE = {
     "front_face": "front_face",
@@ -31,6 +37,8 @@ class CardImageResponse(BaseModel):
     image_type: str
     image_name: Optional[str] = None
     image_path: str
+    detection_image_path: Optional[str] = None
+    modified_image_path: Optional[str] = None
     detection_json: Dict[str, Any]
     modified_json: Optional[Dict[str, Any]] = None
     created_at: datetime
@@ -70,7 +78,7 @@ class CardDetailResponse(BaseModel):
 
 class ImageJsonPairResponse(BaseModel):
     """用于获取单个图片两个JSON数据的响应模型 (主键为 id)"""
-    id: int  # 原 image_id
+    id: int
     detection_json: Dict[str, Any]
     modified_json: Optional[Dict[str, Any]] = None