Ver código fonte

修改算分以及瑕疵截图圈point

袁威 6 dias atrás
pai
commit
7cf0c88054

+ 15 - 25
app/api/formate_xy.py

@@ -158,10 +158,11 @@ _GRAY_IMAGE_TYPES = frozenset({
     ImageType.back_gray.value,
 })
 
-# 以融合图 JSON 中的缺陷为准,在同面下列类型原图上裁图(含灰度图)
+# 以融合图 JSON 中的缺陷为准,在同面下列类型原图上裁图(含灰度图)
 _FRONT_DEFECT_URL_TARGET_TYPES = [
     ImageType.front_fusion.value,
     ImageType.front_ring.value,
+    ImageType.front_gray.value,
     ImageType.front_stripe1.value,
     ImageType.front_stripe2.value,
     ImageType.front_stripe3.value,
@@ -171,6 +172,7 @@ _FRONT_DEFECT_URL_TARGET_TYPES = [
 _BACK_DEFECT_URL_TARGET_TYPES = [
     ImageType.back_fusion.value,
     ImageType.back_ring.value,
+    ImageType.back_gray.value,
     ImageType.back_stripe1.value,
     ImageType.back_stripe2.value,
     ImageType.back_stripe3.value,
@@ -284,7 +286,7 @@ def _defect_url_target_type_list(crop_pool_by_type: dict, side_key: str) -> list
 
 
 def _defect_url_target_images(crop_pool_by_type: dict, side_key: str) -> list:
-    """按固定类型顺序返回同面需生成 defectImgUrls 的图片(灰度图除外)。"""
+    """按固定类型顺序返回同面需生成 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)
@@ -356,9 +358,11 @@ def _generate_defect_img_urls_for_json(
                 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"
+                # _ln:带 points 连线的裁图版本,与旧缓存文件名区分
+                s_filename = f"xy_{card_id}_{s_img_id}_{idx}_{rect_hash}_ln.jpg"
                 s_out_rel_path = f"/DefectImage/{s_filename}"
                 s_out_object_name = f"{settings.MINIO_BASE_PREFIX}{s_out_rel_path}"
+                defect_points = defect.get("points")
 
                 s_url = ""
                 try:
@@ -366,7 +370,9 @@ def _generate_defect_img_urls_for_json(
                     s_url = settings.get_full_url(s_out_rel_path)
                 except Exception:
                     if s_img_path:
-                        s_url = crop_defect_image(s_img_path, min_rect, s_filename)
+                        s_url = crop_defect_image(
+                            s_img_path, min_rect, s_filename, points=defect_points,
+                        )
 
                 if s_url:
                     defect_img_url_list.append({
@@ -403,16 +409,6 @@ def _apply_defect_img_urls_from_cache(json_data: dict, url_cache_by_rect: dict):
         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(
         card_data: dict,
         generate_related_images: bool = True,
@@ -420,8 +416,8 @@ def _process_images_to_xy_format(
 ):
     """
     内部辅助函数:遍历卡牌数据中的图片,将 JSON 格式转换为前端需要的 XY 格式。
-    每面仅以融合图 JSON 生成一次 defectImgUrls(按固定 14 类中的同面非灰类型裁图),
-    再拷贝到同面 ring / stripe 等;灰度图不生成 defectImgUrls
+    每面仅以融合图 JSON 生成一次 defectImgUrls(含 fusion/ring/gray/stripe 等同面类型),
+    再拷贝到同面其它图;裁切图会绘制缺陷 points 连线
     直接修改传入的 card_data 字典。
     """
     start_time = perf_counter()
@@ -475,17 +471,13 @@ def _process_images_to_xy_format(
                     )
 
         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)
+            side_key = _side_key_from_image_type(img.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 is_gray:
-                    _clear_defect_img_urls(d_internal)
-                elif side_key and detection_url_cache_by_side.get(side_key):
+                if 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],
                     )
@@ -494,9 +486,7 @@ def _process_images_to_xy_format(
                 img.detection_json = convert_internal_to_xy_format({})
 
             if m_internal:
-                if is_gray:
-                    _clear_defect_img_urls(m_internal)
-                elif side_key and modified_url_cache_by_side.get(side_key):
+                if 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],
                     )

+ 3 - 1
app/api/rating_report.py

@@ -163,7 +163,9 @@ def generate_rating_report(
         location_str = ""
 
         if min_rect and len(min_rect) == 3:
-            defect_img_url = crop_defect_image(original_img_path, min_rect, filename)
+            defect_img_url = crop_defect_image(
+                original_img_path, min_rect, filename, points=defect.get("points"),
+            )
             center_x, center_y = min_rect[0]
             location_str = f"{int(center_x)},{int(center_y)}"
 

+ 11 - 6
app/crud/crud_card.py

@@ -7,7 +7,7 @@ import copy
 
 from app.core.config import settings
 from app.utils.scheme import CardImageResponse, CardType, SortBy, SortOrder, ImageType
-from app.utils.card_score_calculate import calculate_scores_from_images
+from app.utils.card_score_calculate import calculate_scores_from_images, SCORE_SOURCE_IMAGE_TYPES
 from app.core.logger import get_logger
 
 logger = get_logger(__name__)
@@ -33,7 +33,11 @@ def update_card_scores_and_status(db_conn: PooledMySQLConnection, card_id: int):
         cursor.execute(query_images, (card_id,))
         image_records = cursor.fetchall()
 
-        images = [CardImageResponse.model_validate(row) for row in image_records]
+        images = [
+            CardImageResponse.model_validate(row)
+            for row in image_records
+            if row.get("image_type") in SCORE_SOURCE_IMAGE_TYPES
+        ]
 
         # 2. 计算分数和状态
         scores_data = calculate_scores_from_images(images)
@@ -186,10 +190,11 @@ def get_card_with_details(db_conn: PooledMySQLConnection, card_id: int) -> Optio
             }
             final_images_list.append(CardImageResponse.model_validate(gray_image_dict))
 
-        # 5. 获取分数详情 (只基于主图片计算)
-        # 过滤掉灰度图进行分数计算,防止干扰逻辑
-        main_images_objs = [img for img in final_images_list if
-                            img.image_type not in [ImageType.front_gray.value, ImageType.back_gray.value, ImageType.front_fusion.value, ImageType.back_fusion.value]]
+        # 5. 获取分数详情:fusion/ring/coaxial 参与算分(每面仅用一份 JSON,不重复扣分)
+        main_images_objs = [
+            img for img in final_images_list
+            if img.image_type in SCORE_SOURCE_IMAGE_TYPES
+        ]
         score_details = calculate_scores_from_images(main_images_objs)
 
         # 6. 对图片列表进行自定义排序(14 类:每面 fusion / gray / ring / stripe1-4)

+ 201 - 104
app/utils/card_score_calculate.py

@@ -1,16 +1,101 @@
 from app.utils.scheme import ImageType, CardImageResponse
 from app.core.logger import get_logger
-from typing import List, Dict, Any, Optional
+from typing import List, Dict, Any, Optional, Set, Tuple
 
 logger = get_logger(__name__)
 
+# 参与算分的图片类型(14 图以 fusion 为准,ring 兜底)
+SCORE_SOURCE_IMAGE_TYPES: Set[str] = {
+    ImageType.front_fusion.value,
+    ImageType.back_fusion.value,
+    ImageType.front_ring.value,
+    ImageType.back_ring.value,
+    ImageType.front_coaxial.value,
+    ImageType.back_coaxial.value,
+}
+
+SCORE_BASE = 10.0
+SCORE_MIN = 0.0
+SCORE_MAX = 10.0
+
+
+def _clamp_score(value: float) -> float:
+    """卡牌分数不允许为负,且落在 [0, 10]。"""
+    return max(SCORE_MIN, min(SCORE_MAX, value))
+
+
+def _as_float(value: Any) -> float:
+    try:
+        return float(value or 0)
+    except (TypeError, ValueError):
+        return 0.0
+
+
+def _get_reducts(src: Dict[str, Any], side: str) -> Dict[str, float]:
+    """从单面 JSON 提取扣分字段(兼容 stitch 新版与旧版结构)。"""
+    result = src.get("result", {}) if isinstance(src, dict) else {}
+    defect_result = result.get("defect_result", {}) or {}
+    center_result = result.get("center_result", {}) or {}
+
+    center_deduct = center_result.get("deduct_score")
+    if center_deduct is None:
+        center_deduct = result.get("card_center_deduct_score", 0)
+
+    return {
+        "card_score": result.get("card_score"),
+        "total": _as_float(result.get("_used_compute_deduct_score", 0)),
+        "center": _as_float(center_deduct),
+        "corner": _as_float(defect_result.get(f"{side}_corner_deduct_score", 0)),
+        "edge": _as_float(defect_result.get(f"{side}_edge_deduct_score", 0)),
+        "face": _as_float(defect_result.get(f"{side}_face_deduct_score", 0)),
+    }
+
+
+def _find_image(images: List[CardImageResponse], image_type: str) -> Optional[CardImageResponse]:
+    for img in images:
+        if img.image_type == image_type:
+            return img
+    return None
+
+
+def _resolve_side_score_image(
+        images: List[CardImageResponse],
+        side: str,
+) -> Optional[CardImageResponse]:
+    """每面优先 fusion(stitch JSON 落库处),其次 ring。"""
+    if side == "front":
+        return (
+            _find_image(images, ImageType.front_fusion.value)
+            or _find_image(images, ImageType.front_ring.value)
+        )
+    return (
+        _find_image(images, ImageType.back_fusion.value)
+        or _find_image(images, ImageType.back_ring.value)
+    )
+
+
+def _resolve_side_detection_score(src: Dict[str, Any], side: str = "front") -> float:
+    """
+    单面最终分:
+    - stitch 新版:直接使用 result.card_score(已是该面最终得分)
+    - 旧版兜底:10 + _used_compute_deduct_score
+    """
+    reducts = _get_reducts(src, side)
+    if reducts["card_score"] is not None:
+        return _clamp_score(_as_float(reducts["card_score"]))
+    return _clamp_score(SCORE_BASE + reducts["total"])
+
 
 def calculate_scores_from_images(images: List[CardImageResponse]) -> Dict[str, Any]:
     """
-    根据图片计算分数(兼容 14 图与历史 8 图)。
-    - 必要条件:front_ring + back_ring 存在
-    - face 分数来源:同面 stripe(1~4) / coaxial / fusion,按可用图累加
-    - 不再依赖 coaxial 必须存在
+    根据图片计算分数(兼容 14 图 stitch 与历史 8 图)。
+
+    detection_score:
+      优先取正/反面 fusion(或 ring)JSON 中的 card_score 平均;
+      两面 _used_compute_deduct_score 已是整面总扣分,不可再与 10 分基准相加两次。
+
+    detection_score_detail:
+      各分项仍按 10 分基准 + 对应扣分字段计算。
     """
     scores = {
         "detection_score": None,
@@ -30,132 +115,144 @@ def calculate_scores_from_images(images: List[CardImageResponse]) -> Dict[str, A
         "is_edited": False,
     }
 
-    def _as_float(value: Any) -> float:
-        try:
-            return float(value or 0)
-        except (TypeError, ValueError):
-            return 0.0
-
-    def _get_reducts(src: Dict[str, Any], side: str) -> Dict[str, float]:
-        defect_result = src.get("result", {}).get("defect_result", {})
-        center_result = src.get("result", {}).get("center_result", {})
-        return {
-            "total": _as_float(src.get("result", {}).get("_used_compute_deduct_score", 0)),
-            "center": _as_float(center_result.get("deduct_score", 0)),
-            "corner": _as_float(defect_result.get(f"{side}_corner_deduct_score", 0)),
-            "edge": _as_float(defect_result.get(f"{side}_edge_deduct_score", 0)),
-            "face": _as_float(defect_result.get(f"{side}_face_deduct_score", 0)),
-        }
+    score_images = [img for img in images if img.image_type in SCORE_SOURCE_IMAGE_TYPES]
+    front_side_img = _resolve_side_score_image(score_images, "front")
+    back_side_img = _resolve_side_score_image(score_images, "back")
+    front_coaxial = _find_image(score_images, ImageType.front_coaxial.value)
+    back_coaxial = _find_image(score_images, ImageType.back_coaxial.value)
 
-    front_ring: Optional[CardImageResponse] = None
-    back_ring: Optional[CardImageResponse] = None
-    front_face_imgs: List[CardImageResponse] = []
-    back_face_imgs: List[CardImageResponse] = []
-
-    for img in images:
-        image_type = img.image_type
-        if image_type == ImageType.front_ring:
-            front_ring = img
-        elif image_type == ImageType.back_ring:
-            back_ring = img
-
-        if image_type in [
-            ImageType.front_stripe1, ImageType.front_stripe2,
-            ImageType.front_stripe3, ImageType.front_stripe4,
-            ImageType.front_coaxial, ImageType.front_fusion,
-        ]:
-            front_face_imgs.append(img)
-        elif image_type in [
-            ImageType.back_stripe1, ImageType.back_stripe2,
-            ImageType.back_stripe3, ImageType.back_stripe4,
-            ImageType.back_coaxial, ImageType.back_fusion,
-        ]:
-            back_face_imgs.append(img)
-
-    # ring 是当前模型计算中心/角/边的必要数据
-    if not front_ring or not back_ring:
+    if not front_side_img or not back_side_img:
         return scores
 
+    def _apply_side_detail(
+            side_img: CardImageResponse,
+            side: str,
+            center_score: float,
+            corner_score: float,
+            edge_score: float,
+            face_score: float,
+    ) -> Tuple[float, float, float, float]:
+        reducts = _get_reducts(side_img.detection_json or {}, side)
+        return (
+            center_score + reducts["center"],
+            corner_score + reducts["corner"],
+            edge_score + reducts["edge"],
+            face_score + reducts["face"],
+        )
+
     try:
-        # ---------- detection_score ----------
-        detection_score = 10.0
-        detection_center_score = 10.0
-        detection_corner_score = 10.0
-        detection_edge_score = 10.0
-        detection_face_score = 10.0
-
-        for ring_img, side in ((front_ring, "front"), (back_ring, "back")):
+        front_src = front_side_img.detection_json or {}
+        back_src = back_side_img.detection_json or {}
+        front_side_score = _resolve_side_detection_score(front_src, "front")
+        back_side_score = _resolve_side_detection_score(back_src, "back")
+        scores["detection_score"] = _clamp_score((front_side_score + back_side_score) / 2)
+
+        detection_center_score = SCORE_BASE
+        detection_corner_score = SCORE_BASE
+        detection_edge_score = SCORE_BASE
+        detection_face_score = SCORE_BASE
+
+        for side_img, side in ((front_side_img, "front"), (back_side_img, "back")):
+            try:
+                detection_center_score, detection_corner_score, detection_edge_score, detection_face_score = _apply_side_detail(
+                    side_img, side,
+                    detection_center_score, detection_corner_score, detection_edge_score, detection_face_score,
+                )
+            except Exception as e:
+                logger.warning(f"解析 detection_json 分项失败 (image_id={side_img.id}): {e}")
+
+        if front_coaxial:
             try:
-                reducts = _get_reducts(ring_img.detection_json or {}, side)
-                detection_score += reducts["total"]
-                detection_center_score += reducts["center"]
-                detection_corner_score += reducts["corner"]
-                detection_edge_score += reducts["edge"]
+                reducts = _get_reducts(front_coaxial.detection_json or {}, "front")
                 detection_face_score += reducts["face"]
             except Exception as e:
-                logger.warning(f"解析 detection_json 分数失败 (image_id={ring_img.id}): {e}")
+                logger.warning(f"解析 detection_json 分数失败 (image_id={front_coaxial.id}): {e}")
 
-        # 同面 face 辅助图(stripe/coaxial/fusion)叠加 face 与 total
-        for side, face_imgs in (("front", front_face_imgs), ("back", back_face_imgs)):
-            for img in face_imgs:
-                try:
-                    reducts = _get_reducts(img.detection_json or {}, side)
-                    detection_score += reducts["total"]
-                    detection_face_score += reducts["face"]
-                except Exception as e:
-                    logger.warning(f"解析 detection_json 分数失败 (image_id={img.id}): {e}")
+        if back_coaxial:
+            try:
+                reducts = _get_reducts(back_coaxial.detection_json or {}, "back")
+                detection_face_score += reducts["face"]
+            except Exception as e:
+                logger.warning(f"解析 detection_json 分数失败 (image_id={back_coaxial.id}): {e}")
 
-        scores["detection_score"] = detection_score
         scores["detection_score_detail"] = {
-            "detection_center_score": detection_center_score,
-            "detection_corner_score": detection_corner_score,
-            "detection_edge_score": detection_edge_score,
-            "detection_face_score": detection_face_score,
+            "detection_center_score": _clamp_score(detection_center_score),
+            "detection_corner_score": _clamp_score(detection_corner_score),
+            "detection_edge_score": _clamp_score(detection_edge_score),
+            "detection_face_score": _clamp_score(detection_face_score),
         }
 
-        # ---------- modified_score ----------
-        modified_score = 10.0
-        modified_center_score = 10.0
-        modified_corner_score = 10.0
-        modified_edge_score = 10.0
-        modified_face_score = 10.0
-
-        all_score_images = [front_ring, back_ring] + front_face_imgs + back_face_imgs
-        is_edited = any(img.modified_json is not None for img in all_score_images if img is not None)
+        score_sources = [front_side_img, back_side_img, front_coaxial, back_coaxial]
+        is_edited = any(
+            img and img.modified_json is not None
+            for img in score_sources if img is not None
+        )
         scores["is_edited"] = is_edited
 
         if is_edited:
-            for ring_img, side in ((front_ring, "front"), (back_ring, "back")):
-                src = ring_img.modified_json if ring_img.modified_json is not None else ring_img.detection_json
+            front_m_src = (
+                front_side_img.modified_json
+                if front_side_img.modified_json is not None
+                else front_side_img.detection_json
+            ) or {}
+            back_m_src = (
+                back_side_img.modified_json
+                if back_side_img.modified_json is not None
+                else back_side_img.detection_json
+            ) or {}
+            modified_score = _clamp_score(
+                (
+                    _resolve_side_detection_score(front_m_src, "front")
+                    + _resolve_side_detection_score(back_m_src, "back")
+                ) / 2
+            )
+
+            modified_center_score = SCORE_BASE
+            modified_corner_score = SCORE_BASE
+            modified_edge_score = SCORE_BASE
+            modified_face_score = SCORE_BASE
+
+            for side_img, side in ((front_side_img, "front"), (back_side_img, "back")):
+                src = side_img.modified_json if side_img.modified_json is not None else side_img.detection_json
                 try:
                     reducts = _get_reducts(src or {}, side)
-                    modified_score += reducts["total"]
                     modified_center_score += reducts["center"]
                     modified_corner_score += reducts["corner"]
                     modified_edge_score += reducts["edge"]
                     modified_face_score += reducts["face"]
                 except Exception as e:
-                    logger.warning(f"解析 modified_json 分数失败 (image_id={ring_img.id}): {e}")
-
-            for side, face_imgs in (("front", front_face_imgs), ("back", back_face_imgs)):
-                for img in face_imgs:
-                    src = img.modified_json if img.modified_json is not None else img.detection_json
-                    try:
-                        reducts = _get_reducts(src or {}, side)
-                        modified_score += reducts["total"]
-                        modified_face_score += reducts["face"]
-                    except Exception as e:
-                        logger.warning(f"解析 modified_json 分数失败 (image_id={img.id}): {e}")
+                    logger.warning(f"解析 modified_json 分项失败 (image_id={side_img.id}): {e}")
+
+            if front_coaxial:
+                src = front_coaxial.modified_json if front_coaxial.modified_json is not None else front_coaxial.detection_json
+                try:
+                    modified_face_score += _get_reducts(src or {}, "front")["face"]
+                except Exception as e:
+                    logger.warning(f"解析 modified_json 分数失败 (image_id={front_coaxial.id}): {e}")
+
+            if back_coaxial:
+                src = back_coaxial.modified_json if back_coaxial.modified_json is not None else back_coaxial.detection_json
+                try:
+                    modified_face_score += _get_reducts(src or {}, "back")["face"]
+                except Exception as e:
+                    logger.warning(f"解析 modified_json 分数失败 (image_id={back_coaxial.id}): {e}")
 
             scores["modified_score"] = modified_score
             scores["modified_score_detail"] = {
-                "modified_center_score": modified_center_score,
-                "modified_corner_score": modified_corner_score,
-                "modified_edge_score": modified_edge_score,
-                "modified_face_score": modified_face_score,
+                "modified_center_score": _clamp_score(modified_center_score),
+                "modified_corner_score": _clamp_score(modified_corner_score),
+                "modified_edge_score": _clamp_score(modified_edge_score),
+                "modified_face_score": _clamp_score(modified_face_score),
             }
 
+        logger.info(
+            "算分结果: front_side=%.4f back_side=%.4f detection_score=%s",
+            _resolve_side_detection_score(front_src, "front"),
+            _resolve_side_detection_score(back_src, "back"),
+            scores["detection_score"],
+        )
+
     except Exception as e:
         logger.error(f"计算分数过程异常: {e}")
 
-    return scores
+    return scores

+ 54 - 2
app/utils/rating_report_utils.py

@@ -7,7 +7,7 @@ from datetime import datetime
 from typing import Any, Dict, List, Optional, Tuple
 
 from fastapi import HTTPException
-from PIL import Image
+from PIL import Image, ImageDraw
 from mysql.connector.pooling import PooledMySQLConnection
 
 from app.core.config import settings
@@ -242,9 +242,59 @@ def get_active_json(image_data: Any) -> Optional[Dict]:
     return detection_json
 
 
-def crop_defect_image(original_image_path_str: str, min_rect: List, output_filename: str) -> str:
+def _normalize_defect_points(points: Any) -> List[Tuple[float, float]]:
+    """兼容 [[x,y],...] 与 [{x,y},...] 两种 points 格式。"""
+    if not points or not isinstance(points, list):
+        return []
+    normalized: List[Tuple[float, float]] = []
+    for point in points:
+        if isinstance(point, (list, tuple)) and len(point) >= 2:
+            normalized.append((float(point[0]), float(point[1])))
+        elif isinstance(point, dict) and "x" in point and "y" in point:
+            normalized.append((float(point["x"]), float(point["y"])))
+    return normalized
+
+
+def _draw_defect_points_on_crop(
+        img: Image.Image,
+        points: Any,
+        crop_left: int,
+        crop_top: int,
+) -> None:
+    """在裁切后的缺陷图上绘制 points 多边形连线(坐标转换为裁切区域相对坐标)。"""
+    pts = _normalize_defect_points(points)
+    if len(pts) < 2:
+        return
+
+    crop_w, crop_h = img.size
+    local_pts: List[Tuple[int, int]] = []
+    for x, y in pts:
+        lx = int(round(x - crop_left))
+        ly = int(round(y - crop_top))
+        lx = max(0, min(crop_w - 1, lx))
+        ly = max(0, min(crop_h - 1, ly))
+        local_pts.append((lx, ly))
+
+    draw = ImageDraw.Draw(img)
+    line_color = (255, 0, 0)
+    line_width = max(2, min(crop_w, crop_h) // 80)
+    for i in range(len(local_pts)):
+        draw.line(
+            [local_pts[i], local_pts[(i + 1) % len(local_pts)]],
+            fill=line_color,
+            width=line_width,
+        )
+
+
+def crop_defect_image(
+        original_image_path_str: str,
+        min_rect: List,
+        output_filename: str,
+        points: Optional[Any] = None,
+) -> str:
     """
     通过 MinIO 裁剪缺陷图,生成正方形缺陷图并回传最终 URL。
+    若提供 points,则在裁切图上绘制缺陷轮廓连线。
     """
     start_time = perf_counter()
     try:
@@ -289,6 +339,8 @@ def crop_defect_image(original_image_path_str: str, min_rect: List, output_filen
                 return ""
 
             cropped_img = img.crop((left, top, right, bottom))
+            if points:
+                _draw_defect_points_on_crop(cropped_img, points, left, top)
 
             out_bytes = io.BytesIO()
             cropped_img.save(out_bytes, format="JPEG", quality=95)