袁威 1 неделя назад
Родитель
Сommit
837ab08960
2 измененных файлов с 311 добавлено и 69 удалено
  1. 269 44
      app/api/formate_xy.py
  2. 42 25
      app/crud/crud_card.py

+ 269 - 44
app/api/formate_xy.py

@@ -153,6 +153,146 @@ def _prepare_recalculate_payload(edited_json: dict, source_json: dict) -> dict:
     return base
     return base
 
 
 
 
+_GRAY_IMAGE_TYPES = frozenset({
+    ImageType.front_gray.value,
+    ImageType.back_gray.value,
+})
+
+# 以融合图 JSON 中的缺陷为准,在同面下列类型原图上裁图(不含灰度图)
+_FRONT_DEFECT_URL_TARGET_TYPES = [
+    ImageType.front_fusion.value,
+    ImageType.front_ring.value,
+    ImageType.front_stripe1.value,
+    ImageType.front_stripe2.value,
+    ImageType.front_stripe3.value,
+    ImageType.front_stripe4.value,
+    ImageType.front_coaxial.value,  # 兼容历史同轴光
+]
+_BACK_DEFECT_URL_TARGET_TYPES = [
+    ImageType.back_fusion.value,
+    ImageType.back_ring.value,
+    ImageType.back_stripe1.value,
+    ImageType.back_stripe2.value,
+    ImageType.back_stripe3.value,
+    ImageType.back_stripe4.value,
+    ImageType.back_coaxial.value,
+]
+_DEFECT_URL_TARGET_TYPES_BY_SIDE = {
+    "front": _FRONT_DEFECT_URL_TARGET_TYPES,
+    "back": _BACK_DEFECT_URL_TARGET_TYPES,
+}
+
+# ring / stripe 等可能落在 card_gray_images,裁图时需与主表合并
+_ALL_DEFECT_URL_TARGET_TYPES = frozenset(
+    _FRONT_DEFECT_URL_TARGET_TYPES + _BACK_DEFECT_URL_TARGET_TYPES
+)
+
+
+def _is_gray_image_type(image_type: str) -> bool:
+    return image_type in _GRAY_IMAGE_TYPES
+
+
+def _side_key_from_image_type(image_type: str) -> str:
+    if image_type.startswith("front_"):
+        return "front"
+    if image_type.startswith("back_"):
+        return "back"
+    return ""
+
+
+def _defect_rect_hash(min_rect) -> str:
+    if not min_rect or len(min_rect) != 3:
+        return ""
+    rect_str = str(min_rect)
+    return hashlib.md5(rect_str.encode("utf-8")).hexdigest()[:8]
+
+
+def _resolve_fusion_images_by_side(all_images: list) -> dict:
+    """每面仅以融合图 JSON 作为缺陷与裁图坐标来源。"""
+    type_to_img = {getattr(img, "image_type", ""): img for img in all_images}
+    return {
+        "front": type_to_img.get(ImageType.front_fusion.value),
+        "back": type_to_img.get(ImageType.back_fusion.value),
+    }
+
+
+class _DefectCropImageRef:
+    """裁图用的轻量图片引用(主表 Pydantic 或灰度辅助表行均可)。"""
+
+    __slots__ = ("id", "image_type", "image_path")
+
+    def __init__(self, image_id: int, image_type: str, image_path: str):
+        self.id = image_id
+        self.image_type = image_type
+        self.image_path = image_path
+
+
+def _build_defect_crop_pool_by_type(
+        card_id: int,
+        all_images: list,
+        db_conn: PooledMySQLConnection = None,
+) -> dict:
+    """
+    合并主表 card_images 与辅助表 card_gray_images 中的裁图目标。
+    同类型主表优先;ring/stripe 导入在灰度表时也能被 defectImgUrls 用到。
+    """
+    pool: dict = {}
+    for img in all_images or []:
+        image_type = getattr(img, "image_type", "")
+        if image_type not in _ALL_DEFECT_URL_TARGET_TYPES:
+            continue
+        pool[image_type] = img
+
+    if db_conn is not None:
+        cursor = None
+        try:
+            cursor = db_conn.cursor(dictionary=True)
+            cursor.execute(
+                f"SELECT id, image_type, image_path FROM {settings.DB_GRAY_IMAGE_TABLE_NAME} "
+                f"WHERE card_id = %s",
+                (card_id,),
+            )
+            for row in cursor.fetchall():
+                image_type = row.get("image_type") or ""
+                if image_type not in _ALL_DEFECT_URL_TARGET_TYPES:
+                    continue
+                if image_type in pool:
+                    continue
+                pool[image_type] = _DefectCropImageRef(
+                    row["id"],
+                    image_type,
+                    settings.get_full_url(row.get("image_path")),
+                )
+        finally:
+            if cursor:
+                cursor.close()
+
+    return pool
+
+
+def _defect_url_target_type_list(crop_pool_by_type: dict, side_key: str) -> list:
+    """同面裁图类型列表;若已有 stripe 则不再使用历史 coaxial。"""
+    target_types = list(_DEFECT_URL_TARGET_TYPES_BY_SIDE.get(side_key, []))
+    has_stripe = any(
+        t.startswith(f"{side_key}_stripe") and t in crop_pool_by_type
+        for t in target_types
+    )
+    if has_stripe:
+        coaxial = ImageType.front_coaxial.value if side_key == "front" else ImageType.back_coaxial.value
+        target_types = [t for t in target_types if t != coaxial]
+    return target_types
+
+
+def _defect_url_target_images(crop_pool_by_type: dict, side_key: str) -> list:
+    """按固定类型顺序返回同面需生成 defectImgUrls 的图片(灰度图除外)。"""
+    targets = []
+    for image_type in _defect_url_target_type_list(crop_pool_by_type, side_key):
+        img = crop_pool_by_type.get(image_type)
+        if img is not None:
+            targets.append(img)
+    return targets
+
+
 def _sanitize_defects_for_recalculate(defects: list):
 def _sanitize_defects_for_recalculate(defects: list):
     """
     """
     清理前端展示/编辑辅助字段,减少算分服务解析失败概率。
     清理前端展示/编辑辅助字段,减少算分服务解析失败概率。
@@ -173,47 +313,48 @@ def _sanitize_defects_for_recalculate(defects: list):
         d.pop("new_score", None)
         d.pop("new_score", None)
 
 
 
 
-def _process_defects_for_json(
+def _generate_defect_img_urls_for_json(
         card_id: int,
         card_id: int,
-        img_id: int,
-        img_path: str,
+        fusion_img_id: int,
         json_data: dict,
         json_data: dict,
-        side: str,
-        all_images: list = None,
-        generate_related_images: bool = True
-):
+        side_key: str,
+        crop_pool_by_type: dict,
+        generate_related_images: bool = True,
+) -> dict:
     """
     """
-    为每条缺陷生成同面其他类型图的位置截图列表 defectImgUrls。
-    不再生成主缺陷截图 defectImgUrl,也不再聚合 defectDetailList
+    以融合图 JSON 中的缺陷 min_rect 为准,在同面各目标类型原图上裁图,
+    生成 defectImgUrls 并返回 rect_hash -> urls 缓存,供同面其它图复用
     """
     """
     start_time = perf_counter()
     start_time = perf_counter()
+    url_cache_by_rect = {}
     if not json_data or "result" not in json_data:
     if not json_data or "result" not in json_data:
         logger.info(
         logger.info(
-            "耗时埋点 _process_defects_for_json: card_id=%s image_id=%s side=%s defects=0 elapsed_ms=%.2f",
-            card_id, img_id, side, (perf_counter() - start_time) * 1000
+            "耗时埋点 _generate_defect_img_urls: card_id=%s fusion_image_id=%s side=%s defects=0 elapsed_ms=%.2f",
+            card_id, fusion_img_id, side_key, (perf_counter() - start_time) * 1000,
         )
         )
-        return
+        return url_cache_by_rect
+
     defect_result = json_data["result"].get("defect_result", {})
     defect_result = json_data["result"].get("defect_result", {})
     defects = defect_result.get("defects", [])
     defects = defect_result.get("defects", [])
-
-    side_prefix = "front_" if side.startswith("front_") else "back_"
+    crop_target_images = _defect_url_target_images(crop_pool_by_type, side_key)
+    target_types = _defect_url_target_type_list(crop_pool_by_type, side_key)
+    missing_types = [t for t in target_types if t not in crop_pool_by_type]
+    if missing_types:
+        logger.info(
+            "defectImgUrls 裁图目标缺失: card_id=%s side=%s missing=%s",
+            card_id, side_key, ",".join(missing_types),
+        )
 
 
     for idx, defect in enumerate(defects, start=1):
     for idx, defect in enumerate(defects, start=1):
         min_rect = defect.get("min_rect")
         min_rect = defect.get("min_rect")
         defect_img_url_list = []
         defect_img_url_list = []
+        rect_hash = _defect_rect_hash(min_rect)
 
 
-        if min_rect and len(min_rect) == 3 and generate_related_images and all_images:
-            rect_str = str(min_rect)
-            rect_hash = hashlib.md5(rect_str.encode('utf-8')).hexdigest()[:8]
-
-            same_side_images = [
-                img for img in all_images
-                if getattr(img, 'image_type', '').startswith(side_prefix) and getattr(img, 'id', None) != img_id
-            ]
-            for s_img in same_side_images:
-                s_img_type = getattr(s_img, 'image_type', '')
-                s_img_path = getattr(s_img, 'image_path', '')
-                s_img_id = getattr(s_img, 'id', 0)
+        if min_rect and len(min_rect) == 3 and generate_related_images and crop_target_images:
+            for s_img in crop_target_images:
+                s_img_type = getattr(s_img, "image_type", "")
+                s_img_path = getattr(s_img, "image_path", "")
+                s_img_id = getattr(s_img, "id", 0)
 
 
                 s_filename = f"xy_{card_id}_{s_img_id}_{idx}_{rect_hash}.jpg"
                 s_filename = f"xy_{card_id}_{s_img_id}_{idx}_{rect_hash}.jpg"
                 s_out_rel_path = f"/DefectImage/{s_filename}"
                 s_out_rel_path = f"/DefectImage/{s_filename}"
@@ -230,52 +371,135 @@ def _process_defects_for_json(
                 if s_url:
                 if s_url:
                     defect_img_url_list.append({
                     defect_img_url_list.append({
                         "image_type": s_img_type,
                         "image_type": s_img_type,
-                        "url": s_url
+                        "url": s_url,
                     })
                     })
 
 
         defect["defectImgUrls"] = defect_img_url_list
         defect["defectImgUrls"] = defect_img_url_list
+        if rect_hash:
+            url_cache_by_rect[rect_hash] = copy.deepcopy(defect_img_url_list)
 
 
     logger.info(
     logger.info(
-        "耗时埋点 _process_defects_for_json: card_id=%s image_id=%s side=%s defects=%s elapsed_ms=%.2f",
-        card_id, img_id, side, len(defects), (perf_counter() - start_time) * 1000
+        "耗时埋点 _generate_defect_img_urls: card_id=%s fusion_image_id=%s side=%s "
+        "defects=%s target_types=%s elapsed_ms=%.2f",
+        card_id,
+        fusion_img_id,
+        side_key,
+        len(defects),
+        len(crop_target_images),
+        (perf_counter() - start_time) * 1000,
     )
     )
+    return url_cache_by_rect
+
+
+def _apply_defect_img_urls_from_cache(json_data: dict, url_cache_by_rect: dict):
+    """同面非 canonical 图:按 min_rect 哈希复用已生成的 defectImgUrls,不再访问 MinIO。"""
+    if not json_data or "result" not in json_data or not url_cache_by_rect:
+        return
+    defects = json_data["result"].get("defect_result", {}).get("defects", [])
+    for defect in defects:
+        if not isinstance(defect, dict):
+            continue
+        rect_hash = _defect_rect_hash(defect.get("min_rect"))
+        defect["defectImgUrls"] = copy.deepcopy(url_cache_by_rect.get(rect_hash, []))
+
+
+def _clear_defect_img_urls(json_data: dict):
+    """灰度图不生成关联缺陷图,清空展示用字段。"""
+    if not json_data or "result" not in json_data:
+        return
+    defects = json_data["result"].get("defect_result", {}).get("defects", [])
+    for defect in defects:
+        if isinstance(defect, dict):
+            defect["defectImgUrls"] = []
 
 
 
 
 def _process_images_to_xy_format(
 def _process_images_to_xy_format(
         card_data: dict,
         card_data: dict,
-        generate_related_images: bool = True
+        generate_related_images: bool = True,
+        db_conn: PooledMySQLConnection = None,
 ):
 ):
     """
     """
     内部辅助函数:遍历卡牌数据中的图片,将 JSON 格式转换为前端需要的 XY 格式。
     内部辅助函数:遍历卡牌数据中的图片,将 JSON 格式转换为前端需要的 XY 格式。
+    每面仅以融合图 JSON 生成一次 defectImgUrls(按固定 14 类中的同面非灰类型裁图),
+    再拷贝到同面 ring / stripe 等;灰度图不生成 defectImgUrls。
     直接修改传入的 card_data 字典。
     直接修改传入的 card_data 字典。
     """
     """
     start_time = perf_counter()
     start_time = perf_counter()
     card_id = card_data.get("id")
     card_id = card_data.get("id")
     all_images = card_data.get("images", [])
     all_images = card_data.get("images", [])
+    fusion_by_side = _resolve_fusion_images_by_side(all_images) if all_images else {}
+    crop_pool_by_type = _build_defect_crop_pool_by_type(
+        card_id, all_images, db_conn=db_conn,
+    )
+    detection_url_cache_by_side = {}
+    modified_url_cache_by_side = {}
+
     if all_images:
     if all_images:
+        parsed_json_by_img_id = {}
         for img in all_images:
         for img in all_images:
             d_internal = img.detection_json
             d_internal = img.detection_json
             if isinstance(d_internal, str):
             if isinstance(d_internal, str):
-                d_internal = json.loads(d_internal)
+                d_internal = json.loads(d_internal) if d_internal else None
+            m_internal = img.modified_json
+            if isinstance(m_internal, str):
+                m_internal = json.loads(m_internal) if m_internal else None
+            parsed_json_by_img_id[img.id] = {
+                "detection": d_internal,
+                "modified": m_internal,
+            }
+
+        if generate_related_images:
+            for side_key, fusion_img in fusion_by_side.items():
+                if not fusion_img:
+                    continue
+                parsed = parsed_json_by_img_id.get(fusion_img.id, {})
+                d_internal = parsed.get("detection")
+                if d_internal:
+                    detection_url_cache_by_side[side_key] = _generate_defect_img_urls_for_json(
+                        card_id,
+                        fusion_img.id,
+                        d_internal,
+                        side_key,
+                        crop_pool_by_type,
+                        generate_related_images=True,
+                    )
+                m_internal = parsed.get("modified")
+                if m_internal:
+                    modified_url_cache_by_side[side_key] = _generate_defect_img_urls_for_json(
+                        card_id,
+                        fusion_img.id,
+                        m_internal,
+                        side_key,
+                        crop_pool_by_type,
+                        generate_related_images=True,
+                    )
+
+        for img in all_images:
+            image_type = img.image_type
+            is_gray = _is_gray_image_type(image_type)
+            side_key = _side_key_from_image_type(image_type)
+            parsed = parsed_json_by_img_id.get(img.id, {})
+            d_internal = parsed.get("detection")
+            m_internal = parsed.get("modified")
 
 
             if d_internal:
             if d_internal:
-                _process_defects_for_json(
-                    card_id, img.id, img.image_path, d_internal, img.image_type, all_images,
-                    generate_related_images=generate_related_images
-                )
+                if is_gray:
+                    _clear_defect_img_urls(d_internal)
+                elif side_key and detection_url_cache_by_side.get(side_key):
+                    _apply_defect_img_urls_from_cache(
+                        d_internal, detection_url_cache_by_side[side_key],
+                    )
                 img.detection_json = convert_internal_to_xy_format(d_internal)
                 img.detection_json = convert_internal_to_xy_format(d_internal)
             else:
             else:
                 img.detection_json = convert_internal_to_xy_format({})
                 img.detection_json = convert_internal_to_xy_format({})
 
 
-            m_internal = img.modified_json
-            if isinstance(m_internal, str):
-                m_internal = json.loads(m_internal)
-
             if m_internal:
             if m_internal:
-                _process_defects_for_json(
-                    card_id, img.id, img.image_path, m_internal, img.image_type, all_images,
-                    generate_related_images=generate_related_images
-                )
+                if is_gray:
+                    _clear_defect_img_urls(m_internal)
+                elif side_key and modified_url_cache_by_side.get(side_key):
+                    _apply_defect_img_urls_from_cache(
+                        m_internal, modified_url_cache_by_side[side_key],
+                    )
                 img.modified_json = convert_internal_to_xy_format(m_internal)
                 img.modified_json = convert_internal_to_xy_format(m_internal)
             else:
             else:
                 m_fallback = copy.deepcopy(d_internal) if d_internal else {}
                 m_fallback = copy.deepcopy(d_internal) if d_internal else {}
@@ -352,7 +576,8 @@ def get_card_details(
         # 4. 遍历图片,转换格式 (使用抽取出的辅助函数)
         # 4. 遍历图片,转换格式 (使用抽取出的辅助函数)
         _process_images_to_xy_format(
         _process_images_to_xy_format(
             card_data,
             card_data,
-            generate_related_images=True
+            generate_related_images=True,
+            db_conn=db_conn,
         )
         )
 
 
         # 5. 将 images 从 Pydantic 对象转为 dict,避免 model_validate 重复验证导致类型异常
         # 5. 将 images 从 Pydantic 对象转为 dict,避免 model_validate 重复验证导致类型异常

+ 42 - 25
app/crud/crud_card.py

@@ -136,43 +136,53 @@ def get_card_with_details(db_conn: PooledMySQLConnection, card_id: int) -> Optio
             row['modified_image_path'] = settings.get_full_url(row.get('modified_image_path'))
             row['modified_image_path'] = settings.get_full_url(row.get('modified_image_path'))
             final_images_list.append(CardImageResponse.model_validate(row))
             final_images_list.append(CardImageResponse.model_validate(row))
 
 
-        # 处理灰度图片
+        # 处理 card_gray_images:真灰度图 + ring/stripe 等辅助图(导入时可能落在此表)
         for row in gray_image_records:
         for row in gray_image_records:
             g_type = row['image_type']
             g_type = row['image_type']
-
-            # 确定对应的 Ring 类型
-            target_ring_type = None
-            if g_type == ImageType.front_gray.value:
-                target_ring_type = ImageType.front_ring.value
-            elif g_type == ImageType.back_gray.value:
-                target_ring_type = ImageType.back_ring.value
-
-            # 构造虚拟数据
-            # a. detection_json 写死
-            virtual_detection_json = copy.deepcopy(EMPTY_DETECTION_JSON)
-
-            # b. modified_json 动态计算
-            virtual_modified_json = None
-            if target_ring_type:
+            # 主表已有同类型时跳过,避免 images 列表重复
+            if g_type in main_images_map:
+                continue
+            fusion_type = None
+            if g_type.startswith("front_") and g_type != ImageType.front_gray.value:
+                fusion_type = ImageType.front_fusion.value
+            elif g_type.startswith("back_") and g_type != ImageType.back_gray.value:
+                fusion_type = ImageType.back_fusion.value
+
+            if g_type in (ImageType.front_gray.value, ImageType.back_gray.value):
+                target_ring_type = (
+                    ImageType.front_ring.value
+                    if g_type == ImageType.front_gray.value
+                    else ImageType.back_ring.value
+                )
+                virtual_detection_json = copy.deepcopy(EMPTY_DETECTION_JSON)
                 ring_data = main_images_map.get(target_ring_type)
                 ring_data = main_images_map.get(target_ring_type)
                 virtual_modified_json = _construct_gray_image_json(g_type, ring_data)
                 virtual_modified_json = _construct_gray_image_json(g_type, ring_data)
+            elif fusion_type and fusion_type in main_images_map:
+                # ring / stripe 等在辅助表时,JSON 与融合图共用
+                fusion_row = main_images_map[fusion_type]
+                virtual_detection_json = fusion_row.get("detection_json") or copy.deepcopy(EMPTY_DETECTION_JSON)
+                if isinstance(virtual_detection_json, str):
+                    virtual_detection_json = json.loads(virtual_detection_json)
+                virtual_modified_json = fusion_row.get("modified_json")
+                if isinstance(virtual_modified_json, str):
+                    virtual_modified_json = json.loads(virtual_modified_json)
+            else:
+                virtual_detection_json = copy.deepcopy(EMPTY_DETECTION_JSON)
+                virtual_modified_json = None
 
 
-            # 构造字典以符合 Pydantic 模型
-            # 灰度图表里没有的字段补 None
             gray_image_dict = {
             gray_image_dict = {
                 "id": row['id'],
                 "id": row['id'],
                 "card_id": row['card_id'],
                 "card_id": row['card_id'],
-                "image_type": row['image_type'],
+                "image_type": g_type,
                 "image_path": settings.get_full_url(row['image_path']),
                 "image_path": settings.get_full_url(row['image_path']),
                 "created_at": row['created_at'],
                 "created_at": row['created_at'],
                 "updated_at": row['updated_at'],
                 "updated_at": row['updated_at'],
-                # 虚拟字段
                 "detection_json": virtual_detection_json,
                 "detection_json": virtual_detection_json,
                 "modified_json": virtual_modified_json,
                 "modified_json": virtual_modified_json,
                 "image_name": None,
                 "image_name": None,
                 "detection_image_path": None,
                 "detection_image_path": None,
                 "modified_image_path": None,
                 "modified_image_path": None,
-                "is_edited": False  # 灰度图本身不算被编辑,它只是展示 Ring 的编辑结果
+                "is_edited": False,
             }
             }
             final_images_list.append(CardImageResponse.model_validate(gray_image_dict))
             final_images_list.append(CardImageResponse.model_validate(gray_image_dict))
 
 
@@ -182,8 +192,7 @@ def get_card_with_details(db_conn: PooledMySQLConnection, card_id: int) -> Optio
                             img.image_type not in [ImageType.front_gray.value, ImageType.back_gray.value, ImageType.front_fusion.value, ImageType.back_fusion.value]]
                             img.image_type not in [ImageType.front_gray.value, ImageType.back_gray.value, ImageType.front_fusion.value, ImageType.back_fusion.value]]
         score_details = calculate_scores_from_images(main_images_objs)
         score_details = calculate_scores_from_images(main_images_objs)
 
 
-        # 6. 对图片列表进行自定义排序
-        # 顺序: [back_fusion, front_fusion, front_gray, back_gray, front_ring, back_ring, front_coaxial, back_coaxial]
+        # 6. 对图片列表进行自定义排序(14 类:每面 fusion / gray / ring / stripe1-4)
         sort_priority = {
         sort_priority = {
             ImageType.back_fusion.value: 0,
             ImageType.back_fusion.value: 0,
             ImageType.front_fusion.value: 1,
             ImageType.front_fusion.value: 1,
@@ -191,8 +200,16 @@ def get_card_with_details(db_conn: PooledMySQLConnection, card_id: int) -> Optio
             ImageType.back_gray.value: 3,
             ImageType.back_gray.value: 3,
             ImageType.front_ring.value: 4,
             ImageType.front_ring.value: 4,
             ImageType.back_ring.value: 5,
             ImageType.back_ring.value: 5,
-            ImageType.front_coaxial.value: 6,
-            ImageType.back_coaxial.value: 7
+            ImageType.front_stripe1.value: 6,
+            ImageType.front_stripe2.value: 7,
+            ImageType.front_stripe3.value: 8,
+            ImageType.front_stripe4.value: 9,
+            ImageType.back_stripe1.value: 10,
+            ImageType.back_stripe2.value: 11,
+            ImageType.back_stripe3.value: 12,
+            ImageType.back_stripe4.value: 13,
+            ImageType.front_coaxial.value: 14,
+            ImageType.back_coaxial.value: 15,
         }
         }
         final_images_list.sort(key=lambda x: sort_priority.get(x.image_type, 999))
         final_images_list.sort(key=lambda x: sort_priority.get(x.image_type, 999))