Browse Source

核心算法修改以及入库修改

袁威 1 week ago
parent
commit
2127cb2edf
3 changed files with 553 additions and 295 deletions
  1. 503 281
      app/api/auto_import.py
  2. 22 9
      app/api/images.py
  3. 28 5
      app/utils/scheme.py

+ 503 - 281
app/api/auto_import.py

@@ -1,153 +1,417 @@
-import os
 import json
 import asyncio
 import aiohttp
-from typing import Dict, Any, Tuple, Optional, Union
+from typing import Dict, Any, Tuple, Optional, Union, List
 from fastapi import APIRouter, HTTPException, Request, UploadFile, File, Form
 
 from app.core.config import settings
 from app.core.logger import get_logger
-from app.utils.scheme import CardType, IMAGE_TYPE_TO_SCORE_TYPE
+from app.utils.scheme import CardType, ImageType
 
 logger = get_logger(__name__)
 router = APIRouter()
 
 IMPORT_REQUEST_TIMEOUT = aiohttp.ClientTimeout(
-    total=600,
-    connect=10,
-    sock_connect=10,
-    sock_read=480,
+    total=3600,
+    connect=30,
+    sock_connect=30,
+    sock_read=1800,
 )
 
+# 推理服务 stitch 接口需要的表单字段顺序,按 ring + gray + stripe1..4 组装
+STITCH_FORM_FIELDS = ["ring", "gray", "stripe1", "stripe2", "stripe3", "stripe4"]
+
+# 一面(front/back)对应的所有 image_type
+SIDE_IMAGE_TYPES: Dict[str, Dict[str, str]] = {
+    "front": {
+        "fusion": ImageType.front_fusion.value,
+        "ring": ImageType.front_ring.value,
+        "gray": ImageType.front_gray.value,
+        "stripe1": ImageType.front_stripe1.value,
+        "stripe2": ImageType.front_stripe2.value,
+        "stripe3": ImageType.front_stripe3.value,
+        "stripe4": ImageType.front_stripe4.value,
+    },
+    "back": {
+        "fusion": ImageType.back_fusion.value,
+        "ring": ImageType.back_ring.value,
+        "gray": ImageType.back_gray.value,
+        "stripe1": ImageType.back_stripe1.value,
+        "stripe2": ImageType.back_stripe2.value,
+        "stripe3": ImageType.back_stripe3.value,
+        "stripe4": ImageType.back_stripe4.value,
+    },
+}
+
+# 主表落库的图(带 JSON)
+MAIN_IMAGE_TYPES = {ImageType.front_fusion.value, ImageType.back_fusion.value}
+
+
+def _flat_image_types() -> List[str]:
+    """按接口参数顺序展开 14 个 image_type,便于日志输出。"""
+    flat = []
+    for side in ("front", "back"):
+        side_map = SIDE_IMAGE_TYPES[side]
+        flat.extend([
+            side_map["fusion"],
+            side_map["ring"],
+            side_map["gray"],
+            side_map["stripe1"],
+            side_map["stripe2"],
+            side_map["stripe3"],
+            side_map["stripe4"],
+        ])
+    return flat
+
 
 # --- 内部辅助函数 ---
-async def call_api_with_bytes(
+async def _post_form(
         session: aiohttp.ClientSession,
         url: str,
-        file_bytes: bytes,
-        filename: str,
-        params: Dict[str, Any] = None,
-        form_fields: Dict[str, Any] = None,
-        file_field_name: str = 'file'
+        files: List[Tuple[str, bytes, str] | Tuple[str, bytes, str, str]],
+        params: Optional[Dict[str, Any]] = None,
+        form_fields: Optional[Dict[str, Any]] = None,
 ) -> Tuple[int, bytes]:
-    """通用的文件上传API调用函数 (接收字节数据)"""
+    """通用 multipart 调用:files 元素为 (field_name, file_bytes, filename[, content_type])。"""
     form_data = aiohttp.FormData()
 
     if form_fields:
         for key, value in form_fields.items():
             form_data.add_field(key, str(value))
 
-    # 直接将内存中的字节数据添加到表单
-    form_data.add_field(
-        file_field_name,
-        file_bytes,
-        filename=filename or 'image.jpg',
-        content_type='image/jpeg'
-    )
+    for file_item in files:
+        if len(file_item) == 4:
+            field_name, file_bytes, filename, content_type = file_item
+        else:
+            field_name, file_bytes, filename = file_item
+            content_type = "image/jpeg"
+        form_data.add_field(
+            field_name,
+            file_bytes,
+            filename=filename or f"{field_name}.jpg",
+            content_type=content_type,
+        )
 
     try:
         async with session.post(url, data=form_data, params=params) as response:
             response_content = await response.read()
             if not response.ok:
                 logger.error(
-                    f"[API Error] {url} -> Status: {response.status}, Msg: {response_content.decode('utf-8', errors='ignore')[:100]}")
+                    "[API Error] %s -> Status: %s, Msg: %s",
+                    url,
+                    response.status,
+                    response_content.decode("utf-8", errors="ignore")[:200],
+                )
             return response.status, response_content
     except Exception as e:
         logger.error(f"[Conn Error] {url} -> {e}")
-        raise e
+        raise
 
 
-async def process_main_image(
+async def call_stitch_inference(
         session: aiohttp.ClientSession,
-        file_bytes: bytes,
-        filename: str,
-        image_type: str,
-        is_reflect_card: str
+        side: str,
+        card_name: str,
+        side_bytes: Dict[str, Tuple[bytes, str]],
 ) -> Dict[str, Any]:
-    """调用推理服务,处理主图片"""
-    score_type = IMAGE_TYPE_TO_SCORE_TYPE.get(image_type)
-    if not score_type:
-        raise HTTPException(status_code=400, detail=f"不支持的主图类型: {image_type}")
+    """
+    调用 stitch_score_inference 接口,一次处理一面(front/back)。
+    side_bytes 的 key 必须包含 STITCH_FORM_FIELDS 全部字段,value 是 (bytes, filename)。
+    """
+    missing = [k for k in STITCH_FORM_FIELDS if k not in side_bytes]
+    if missing:
+        raise HTTPException(
+            status_code=400,
+            detail=f"{side} 面缺少推理所需图片字段: {missing}",
+        )
+
+    files: List[Tuple[str, bytes, str]] = []
+    for field in STITCH_FORM_FIELDS:
+        f_bytes, f_name = side_bytes[field]
+        if len(f_bytes) == 0:
+            raise HTTPException(status_code=400, detail=f"{side} 面 {field} 文件内容为空: {f_name}")
+        files.append((field, f_bytes, f_name))
 
-    logger.info(f"处理主图: image_type={image_type}, score_type={score_type} -> {filename}")
     inference_base_url = settings.SCORE_UPDATE_SERVER_URL
+    url = f"{inference_base_url}/api/card_score/stitch_score_inference"
+    params = {"score_type": side, "card_name": card_name}
 
-    # 1. 获取转正后的图片
-    rectify_url = f"{inference_base_url}/api/card_inference/card_rectify_and_center"
-    rectify_status, rectified_image_bytes = await call_api_with_bytes(
-        session, url=rectify_url, file_bytes=file_bytes, filename=filename
+    logger.info(
+        "调用 stitch_score_inference: side=%s card_name=%s fields=%s",
+        side, card_name, ",".join(STITCH_FORM_FIELDS),
     )
-    if rectify_status >= 300:
-        raise HTTPException(status_code=500, detail=f"图片转正失败: {image_type}")
 
-    # 2. 获取分数JSON
-    score_url = f"{inference_base_url}/api/card_score/score_inference"
-    score_params = {"score_type": score_type, "is_reflect_card": is_reflect_card}
+    status, body = await _post_form(session, url=url, files=files, params=params)
+    if status >= 300:
+        raise HTTPException(status_code=500, detail=f"{side} 面 stitch 推理失败 status={status}")
+
+    try:
+        return json.loads(body)
+    except json.JSONDecodeError as e:
+        raise HTTPException(status_code=500, detail=f"{side} 面推理结果解析失败: {e}")
+
 
-    score_status, score_json_bytes = await call_api_with_bytes(
-        session, url=score_url, file_bytes=file_bytes, filename=filename, params=score_params
+async def call_rectify_and_center(
+        session: aiohttp.ClientSession,
+        image_type: str,
+        file_bytes: bytes,
+        filename: str,
+) -> Tuple[bytes, str]:
+    """调用推理服务转正居中接口,返回转正后的图片 bytes。"""
+    if len(file_bytes) == 0:
+        raise HTTPException(status_code=400, detail=f"图片文件 {image_type} 内容为空: {filename}")
+
+    inference_base_url = settings.SCORE_UPDATE_SERVER_URL
+    url = f"{inference_base_url}/api/card_inference/card_rectify_and_center"
+
+    logger.info("调用 card_rectify_and_center: image_type=%s filename=%s", image_type, filename)
+    status, body = await _post_form(
+        session,
+        url=url,
+        files=[("file", file_bytes, filename or f"{image_type}.jpg")],
     )
-    if score_status >= 300:
-        raise HTTPException(status_code=500, detail=f"推理分数失败: {image_type}")
+    if status >= 300:
+        raise HTTPException(status_code=500, detail=f"图片转正居中失败: {image_type} status={status}")
 
-    return {
-        "image_type": image_type,
-        "rectified_image": rectified_image_bytes,
-        "score_json": json.loads(score_json_bytes)
-    }
+    name_root = filename.rsplit(".", 1)[0] if filename else image_type
+    return body, f"{name_root}_rectified.jpg"
 
 
-async def create_card_record(session: aiohttp.ClientSession, base_url: str, card_name: str, cardNo: Optional[str],
-                             card_type: CardType) -> int:
+async def rectify_side_images(
+        session: aiohttp.ClientSession,
+        side: str,
+        side_bytes: Dict[str, Tuple[bytes, str]],
+) -> Dict[str, Tuple[bytes, str]]:
+    """将 stitch 需要的一面 6 张图全部转正居中。"""
+    side_map = SIDE_IMAGE_TYPES[side]
+
+    async def rectify_one(field: str) -> Tuple[str, Tuple[bytes, str]]:
+        file_bytes, filename = side_bytes[field]
+        image_type = side_map[field]
+        rectified_bytes, rectified_filename = await call_rectify_and_center(
+            session=session,
+            image_type=image_type,
+            file_bytes=file_bytes,
+            filename=filename,
+        )
+        return field, (rectified_bytes, rectified_filename)
+
+    logger.info("%s 面开始转正居中: fields=%s", side, ",".join(STITCH_FORM_FIELDS))
+    rectified_pairs = await asyncio.gather(*[rectify_one(field) for field in STITCH_FORM_FIELDS])
+    logger.info("%s 面转正居中完成", side)
+    return dict(rectified_pairs)
+
+
+async def create_card_record(
+        session: aiohttp.ClientSession,
+        base_url: str,
+        card_name: str,
+        cardNo: Optional[str],
+        card_type: CardType,
+) -> int:
     """调用自身服务创建新的卡牌记录"""
     url = f"{base_url}{settings.API_PREFIX}/cards/created"
-    params = {'card_name': card_name, 'card_type': card_type.value}
+    params = {"card_name": card_name, "card_type": card_type.value}
     if cardNo:
-        params['cardNo'] = cardNo
+        params["cardNo"] = cardNo
 
     async with session.post(url, params=params) as response:
         if response.status == 201:
             data = await response.json()
-            return data.get('id')
-        else:
-            text = await response.text()
-            raise HTTPException(status_code=response.status, detail=f"创建卡牌记录失败: {text}")
+            return data.get("id")
+        text = await response.text()
+        raise HTTPException(status_code=response.status, detail=f"创建卡牌记录失败: {text}")
 
 
-async def upload_main_image(session: aiohttp.ClientSession, base_url: str, card_id: int,
-                            processed_data: Dict[str, Any]):
-    """将处理后的主图和JSON上传到自身服务"""
-    image_type = processed_data['image_type']
+async def upload_fusion_image(
+        session: aiohttp.ClientSession,
+        base_url: str,
+        card_id: int,
+        image_type: str,
+        file_bytes: bytes,
+        filename: str,
+        score_json: Dict[str, Any],
+):
+    """融合图带 JSON 走主图入库接口。"""
     url = f"{base_url}{settings.API_PREFIX}/images/insert/{card_id}"
 
-    form_data = aiohttp.FormData()
-    form_data.add_field('image_type', image_type)
-    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=f'{image_type}_rectified.jpg',
-        content_type='image/jpeg'
+    form_fields = {
+        "image_type": image_type,
+    }
+    json_bytes = json.dumps(score_json, ensure_ascii=False).encode("utf-8")
+    status, body = await _post_form(
+        session,
+        url=url,
+        files=[
+            ("image", file_bytes, filename or f"{image_type}.jpg"),
+            ("json_data_file", json_bytes, f"{image_type}_score.json", "application/json"),
+        ],
+        form_fields=form_fields,
     )
-
-    async with session.post(url, data=form_data) as response:
-        if response.status != 201:
-            logger.error(f"[主图上传失败] {image_type} code={response.status}")
-            raise HTTPException(status_code=500, detail=f"主图保存失败: {image_type}")
+    if status != 201:
+        logger.error(
+            "[融合图上传失败] image_type=%s status=%s msg=%s",
+            image_type, status, body.decode("utf-8", errors="ignore")[:200],
+        )
+        raise HTTPException(status_code=500, detail=f"融合图保存失败: {image_type}")
 
 
-async def upload_gray_image(session: aiohttp.ClientSession, base_url: str, card_id: int, image_type: str,
-                            file_bytes: bytes, filename: str):
-    """将灰度图源文件上传到自身服务"""
+async def upload_auxiliary_image(
+        session: aiohttp.ClientSession,
+        base_url: str,
+        card_id: int,
+        image_type: str,
+        file_bytes: bytes,
+        filename: str,
+):
+    """ring / gray / stripe 这些辅助图走灰度图入库接口(不带 JSON)。"""
     url = f"{base_url}{settings.API_PREFIX}/images/insert/gray/{card_id}"
-    form_fields = {'image_type': image_type}
+    form_fields = {"image_type": image_type}
 
-    status, _ = await call_api_with_bytes(
-        session, url=url, file_bytes=file_bytes, filename=filename, form_fields=form_fields, file_field_name='image'
+    status, body = await _post_form(
+        session,
+        url=url,
+        files=[("image", file_bytes, filename or f"{image_type}.jpg")],
+        form_fields=form_fields,
     )
     if status != 201:
-        logger.error(f"[灰度图上传失败] {image_type} code={status}")
-        raise HTTPException(status_code=500, detail=f"灰度图保存失败: {image_type}")
+        logger.error(
+            "[辅助图上传失败] image_type=%s status=%s msg=%s",
+            image_type, status, body.decode("utf-8", errors="ignore")[:200],
+        )
+        raise HTTPException(status_code=500, detail=f"辅助图保存失败: {image_type}")
+
+
+def _collect_side_bytes(
+        side: str,
+        bytes_map: Dict[str, Tuple[bytes, str]],
+) -> Optional[Dict[str, Tuple[bytes, str]]]:
+    """
+    根据一面(front/back)的 image_type,从 bytes_map 中抽出 stitch 推理所需的 6 张图。
+    如果有任意一张缺失则返回 None,表示该面无法推理。
+    """
+    side_map = SIDE_IMAGE_TYPES[side]
+    side_bytes: Dict[str, Tuple[bytes, str]] = {}
+    for field in STITCH_FORM_FIELDS:
+        image_type = side_map[field]
+        if image_type not in bytes_map:
+            return None
+        side_bytes[field] = bytes_map[image_type]
+    return side_bytes
+
+
+async def _run_import_flow(
+        local_base_url: str,
+        card_name: str,
+        cardNo: Optional[str],
+        card_type: CardType,
+        strict_mode: bool,
+        bytes_map: Dict[str, Tuple[bytes, str]],
+) -> Dict[str, Any]:
+    """
+    共用导入流程:bytes_map 的 key 是 image_type,value 是 (bytes, filename)。
+    """
+    expected_types = _flat_image_types()
+    provided = [t for t in expected_types if t in bytes_map]
+    missing = [t for t in expected_types if t not in bytes_map]
+
+    if strict_mode and missing:
+        raise HTTPException(
+            status_code=400,
+            detail=f"严格模式开启,必须提供全部 14 张图,缺少: {missing}",
+        )
+    if not provided:
+        raise HTTPException(status_code=400, detail="未提供任何图片文件,无法创建。")
+
+    logger.info(
+        "auto_import start card_name=%s cardNo=%s card_type=%s strict_mode=%s provided=%s missing=%s",
+        card_name,
+        cardNo or "",
+        card_type.value,
+        strict_mode,
+        ",".join(provided) or "none",
+        ",".join(missing) or "none",
+    )
+
+    connector = aiohttp.TCPConnector(limit=20, force_close=True)
+    async with aiohttp.ClientSession(timeout=IMPORT_REQUEST_TIMEOUT, connector=connector) as session:
+        # 1. 正反面分别调用 stitch 推理(要求该面 6 张推理图齐全)
+        side_score_json: Dict[str, Optional[Dict[str, Any]]] = {"front": None, "back": None}
+        for side in ("front", "back"):
+            side_bytes = _collect_side_bytes(side, bytes_map)
+            if side_bytes is None:
+                logger.warning("%s 面推理跳过,缺少 ring/gray/stripe1..4 中的某些图", side)
+                continue
+            rectified_side_bytes = await rectify_side_images(
+                session=session,
+                side=side,
+                side_bytes=side_bytes,
+            )
+            side_score_json[side] = await call_stitch_inference(
+                session=session,
+                side=side,
+                card_name=card_name,
+                side_bytes=rectified_side_bytes,
+            )
+
+        # 2. 创建卡牌
+        card_id = await create_card_record(session, local_base_url, card_name, cardNo, card_type)
+        logger.info("auto_import card_created card_name=%s card_id=%s", card_name, card_id)
+
+        # 3. 串行上传所有图:避免同时打满本服务的数据库连接池
+        upload_count = 0
+        for side in ("front", "back"):
+            side_map = SIDE_IMAGE_TYPES[side]
+            fusion_type = side_map["fusion"]
+            fusion_data = bytes_map.get(fusion_type)
+            score_json = side_score_json.get(side)
+
+            if fusion_data is not None:
+                f_bytes, f_name = fusion_data
+                if score_json is not None:
+                    await upload_fusion_image(
+                        session, local_base_url, card_id,
+                        fusion_type, f_bytes, f_name, score_json,
+                    )
+                    upload_count += 1
+                else:
+                    # 没有该面推理结果,融合图退化为辅助图,避免主表 detection_json 为空
+                    logger.warning(
+                        "%s 面缺少 stitch 推理结果,融合图 %s 退化为辅助图入库",
+                        side, fusion_type,
+                    )
+                    await upload_auxiliary_image(
+                        session, local_base_url, card_id,
+                        fusion_type, f_bytes, f_name,
+                    )
+                    upload_count += 1
+
+            # ring / gray / stripe1..4 走辅助表
+            for field in STITCH_FORM_FIELDS:
+                aux_type = side_map[field]
+                aux_data = bytes_map.get(aux_type)
+                if aux_data is None:
+                    continue
+                a_bytes, a_name = aux_data
+                await upload_auxiliary_image(
+                    session, local_base_url, card_id,
+                    aux_type, a_bytes, a_name,
+                )
+                upload_count += 1
+
+        logger.info(
+            "auto_import finished card_name=%s card_id=%s upload_task_count=%s "
+            "front_score=%s back_score=%s",
+            card_name, card_id, upload_count,
+            "yes" if side_score_json["front"] is not None else "no",
+            "yes" if side_score_json["back"] is not None else "no",
+        )
+        return {
+            "message": "导入成功",
+            "card_id": card_id,
+            "card_name": card_name,
+            "cardNo": cardNo,
+        }
 
 
 # --- 暴露的API接口 ---
@@ -158,126 +422,93 @@ async def auto_import_script_api(
         card_name: str = Form(..., description="卡牌名称"),
         cardNo: Optional[str] = Form(None, description="卡牌编号"),
         card_type: CardType = Form(CardType.pokemon, description="卡牌类型"),
-        is_reflect_card: bool = Form(True, description="是否是反光卡"),
-        strict_mode: bool = Form(False, description="如果为True,必须提供所有4张主图"),
-
-        front_ring: Union[UploadFile, str, None] = File(None, description="正面环光图文件"),
-        front_coaxial: Union[UploadFile, str, None] = File(None, description="正面同轴光图文件"),
-        back_ring: Union[UploadFile, str, None] = File(None, description="背面环光图文件"),
-        back_coaxial: Union[UploadFile, str, None] = File(None, description="背面同轴光图文件"),
-        front_gray: Union[UploadFile, str, None] = File(None, description="正面灰度图文件"),
-        back_gray: Union[UploadFile, str, None] = File(None, description="背面灰度图文件")
+        is_reflect_card: bool = Form(True, description="是否是反光卡(保留参数,兼容旧调用)"),
+        strict_mode: bool = Form(False, description="如果为True,必须提供所有14张图"),
+
+        front_fusion: Union[UploadFile, str, None] = File(None, description="正面融合图"),
+        back_fusion: Union[UploadFile, str, None] = File(None, description="反面融合图"),
+
+        front_ring: Union[UploadFile, str, None] = File(None, description="正面环光图"),
+        front_gray: Union[UploadFile, str, None] = File(None, description="正面灰度图"),
+        front_stripe1: Union[UploadFile, str, None] = File(None, description="正面调光图1"),
+        front_stripe2: Union[UploadFile, str, None] = File(None, description="正面调光图2"),
+        front_stripe3: Union[UploadFile, str, None] = File(None, description="正面调光图3"),
+        front_stripe4: Union[UploadFile, str, None] = File(None, description="正面调光图4"),
+
+        back_ring: Union[UploadFile, str, None] = File(None, description="反面环光图"),
+        back_gray: Union[UploadFile, str, None] = File(None, description="反面灰度图"),
+        back_stripe1: Union[UploadFile, str, None] = File(None, description="反面调光图1"),
+        back_stripe2: Union[UploadFile, str, None] = File(None, description="反面调光图2"),
+        back_stripe3: Union[UploadFile, str, None] = File(None, description="反面调光图3"),
+        back_stripe4: Union[UploadFile, str, None] = File(None, description="反面调光图4"),
 ):
-    local_base_url = str(request.base_url).rstrip('/')
-
-    main_inputs = {
-        "front_ring": front_ring,
-        "front_coaxial": front_coaxial,
-        "back_ring": back_ring,
-        "back_coaxial": back_coaxial
-    }
-    gray_inputs = {
-        "front_gray": front_gray,
-        "back_gray": back_gray
+    """
+    按 image_type 直接命名 14 个文件字段:
+      front_fusion / back_fusion
+      front_ring / back_ring
+      front_gray / back_gray
+      front_stripe1..4 / back_stripe1..4
+
+    推理服务调用 stitch_score_inference,按面(front/back)一次性提交
+    ring + gray + stripe1..4,返回的 JSON 落到对应 fusion 主图记录中。
+    """
+    local_base_url = str(request.base_url).rstrip("/")
+
+    raw_inputs: Dict[str, Union[UploadFile, str, None]] = {
+        ImageType.front_fusion.value: front_fusion,
+        ImageType.back_fusion.value: back_fusion,
+        ImageType.front_ring.value: front_ring,
+        ImageType.front_gray.value: front_gray,
+        ImageType.front_stripe1.value: front_stripe1,
+        ImageType.front_stripe2.value: front_stripe2,
+        ImageType.front_stripe3.value: front_stripe3,
+        ImageType.front_stripe4.value: front_stripe4,
+        ImageType.back_ring.value: back_ring,
+        ImageType.back_gray.value: back_gray,
+        ImageType.back_stripe1.value: back_stripe1,
+        ImageType.back_stripe2.value: back_stripe2,
+        ImageType.back_stripe3.value: back_stripe3,
+        ImageType.back_stripe4.value: back_stripe4,
     }
 
-    valid_main_files = {
-        k: v for k, v in main_inputs.items()
-        if (v is not None) and v.filename
-    }
-    valid_gray_files = {
-        k: v for k, v in gray_inputs.items()
-        if (v is not None) and v.filename
+    valid_uploads: Dict[str, Any] = {
+        k: v for k, v in raw_inputs.items()
+        if getattr(v, "filename", None) and callable(getattr(v, "read", None))
     }
 
-    provided_main_count = len(valid_main_files)
-    if strict_mode and provided_main_count != 4:
-        raise HTTPException(status_code=400, detail=f"严格模式开启,必须提供所有4张主图。")
-    if not strict_mode and provided_main_count == 0 and not valid_gray_files:
-        raise HTTPException(status_code=400, detail="未提供任何图片文件,无法创建。")
+    # 读取所有字节
+    bytes_map: Dict[str, Tuple[bytes, str]] = {}
+    for image_type, upload in valid_uploads.items():
+        data = await upload.read()
+        if len(data) == 0:
+            raise HTTPException(status_code=400, detail=f"图片文件 {image_type} 内容为空")
+        bytes_map[image_type] = (data, upload.filename)
 
     logger.info(
-        "auto_import_script_api start card_name=%s cardNo=%s card_type=%s is_reflect_card=%s strict_mode=%s "
-        "main_types=%s gray_types=%s",
-        card_name,
-        cardNo or "",
-        card_type.value,
+        "auto_import_script_api received is_reflect_card=%s strict_mode=%s provided_count=%s",
         is_reflect_card,
         strict_mode,
-        ",".join(valid_main_files.keys()) or "none",
-        ",".join(valid_gray_files.keys()) or "none",
+        len(bytes_map),
     )
 
-    is_reflect_str = "true" if is_reflect_card else "false"
-
-    connector = aiohttp.TCPConnector(limit=20, force_close=True)
-
-    async with aiohttp.ClientSession(timeout=IMPORT_REQUEST_TIMEOUT, connector=connector) as session:
-        try:
-            main_bytes_data = {k: (await v.read(), v.filename) for k, v in valid_main_files.items()}
-            gray_bytes_data = {k: (await v.read(), v.filename) for k, v in valid_gray_files.items()}
-
-            logger.info(
-                "auto_import_script_api files_loaded card_name=%s main_count=%s gray_count=%s",
-                card_name,
-                len(main_bytes_data),
-                len(gray_bytes_data),
-            )
-            processed_results = []
-            for img_type, (f_bytes, f_name) in main_bytes_data.items():
-                if len(f_bytes) == 0:
-                    raise HTTPException(status_code=400, detail=f"图片文件 {f_name} 内容为空")
-
-                res = await process_main_image(session, f_bytes, f_name, img_type, is_reflect_str)
-                processed_results.append(res)
-
-            card_id = await create_card_record(
-                session, local_base_url, card_name, cardNo, card_type
-            )
-            logger.info(
-                "auto_import_script_api card_created card_name=%s card_id=%s processed_main_count=%s gray_count=%s",
-                card_name,
-                card_id,
-                len(processed_results),
-                len(gray_bytes_data),
-            )
-
-            # ---------- 修改点:safe_upload_task 调用 + gather ----------
-            upload_tasks = []
-            for res in processed_results:
-                upload_tasks.append(upload_main_image(session, local_base_url, card_id, res))
-
-            for img_type, (f_bytes, f_name) in gray_bytes_data.items():
-                upload_tasks.append(upload_gray_image(session, local_base_url, card_id, img_type, f_bytes, f_name))
-
-            if upload_tasks:
-                await asyncio.gather(*upload_tasks)
-            # ------------------------------------------------------------
-
-            logger.info(
-                "auto_import_script_api finished card_name=%s card_id=%s upload_task_count=%s",
-                card_name,
-                card_id,
-                len(upload_tasks),
-            )
-            return {
-                "message": "导入成功",
-                "card_id": card_id,
-                "card_name": card_name,
-                "cardNo": cardNo
-            }
-
-        except HTTPException as e:
-            logger.error(
-                "auto_import_script_api failed_http card_name=%s detail=%s status_code=%s",
-                card_name,
-                e.detail,
-                e.status_code,
-            )
-            raise
-        except Exception as e:
-            logger.exception("auto_import_script_api failed_unexpected card_name=%s", card_name)
-            raise HTTPException(status_code=500, detail=f"自动化处理异常: {str(e)}")
+    try:
+        return await _run_import_flow(
+            local_base_url=local_base_url,
+            card_name=card_name,
+            cardNo=cardNo,
+            card_type=card_type,
+            strict_mode=strict_mode,
+            bytes_map=bytes_map,
+        )
+    except HTTPException as e:
+        logger.error(
+            "auto_import_script_api failed_http card_name=%s detail=%s status_code=%s",
+            card_name, e.detail, e.status_code,
+        )
+        raise
+    except Exception as e:
+        logger.exception("auto_import_script_api failed_unexpected card_name=%s", card_name)
+        raise HTTPException(status_code=500, detail=f"自动化处理异常: {e}")
 
 
 @router.post("/process_and_import_url", summary="通过URL自动化处理并导入卡牌数据")
@@ -286,102 +517,93 @@ async def auto_import_url_script_api(
         card_name: str = Form(..., description="卡牌名称"),
         cardNo: Optional[str] = Form(None, description="卡牌编号"),
         card_type: CardType = Form(CardType.pokemon, description="卡牌类型"),
-        is_reflect_card: bool = Form(True, description="是否是反光卡"),
-        strict_mode: bool = Form(False, description="如果为True,必须提供所有4张主图URL"),
+        is_reflect_card: bool = Form(True, description="是否是反光卡(保留参数,兼容旧调用)"),
+        strict_mode: bool = Form(False, description="如果为True,必须提供所有14张图URL"),
+
+        front_fusion: Optional[str] = Form(None, description="正面融合图URL"),
+        back_fusion: Optional[str] = Form(None, description="反面融合图URL"),
 
         front_ring: Optional[str] = Form(None, description="正面环光图URL"),
-        front_coaxial: Optional[str] = Form(None, description="正面同轴光图URL"),
-        back_ring: Optional[str] = Form(None, description="背面环光图URL"),
-        back_coaxial: Optional[str] = Form(None, description="背面同轴光图URL"),
         front_gray: Optional[str] = Form(None, description="正面灰度图URL"),
-        back_gray: Optional[str] = Form(None, description="背面灰度图URL")
+        front_stripe1: Optional[str] = Form(None, description="正面调光图1 URL"),
+        front_stripe2: Optional[str] = Form(None, description="正面调光图2 URL"),
+        front_stripe3: Optional[str] = Form(None, description="正面调光图3 URL"),
+        front_stripe4: Optional[str] = Form(None, description="正面调光图4 URL"),
+
+        back_ring: Optional[str] = Form(None, description="反面环光图URL"),
+        back_gray: Optional[str] = Form(None, description="反面灰度图URL"),
+        back_stripe1: Optional[str] = Form(None, description="反面调光图1 URL"),
+        back_stripe2: Optional[str] = Form(None, description="反面调光图2 URL"),
+        back_stripe3: Optional[str] = Form(None, description="反面调光图3 URL"),
+        back_stripe4: Optional[str] = Form(None, description="反面调光图4 URL"),
 ):
-    logger.info(f"--- 开始URL导入任务:auto_import_url_script_api: {card_name} ---")
-    local_base_url = str(request.base_url).rstrip('/')
-
-    main_inputs = {
-        "front_ring": front_ring, "front_coaxial": front_coaxial,
-        "back_ring": back_ring, "back_coaxial": back_coaxial
-    }
-    gray_inputs = {
-        "front_gray": front_gray, "back_gray": back_gray
+    local_base_url = str(request.base_url).rstrip("/")
+
+    url_inputs: Dict[str, Optional[str]] = {
+        ImageType.front_fusion.value: front_fusion,
+        ImageType.back_fusion.value: back_fusion,
+        ImageType.front_ring.value: front_ring,
+        ImageType.front_gray.value: front_gray,
+        ImageType.front_stripe1.value: front_stripe1,
+        ImageType.front_stripe2.value: front_stripe2,
+        ImageType.front_stripe3.value: front_stripe3,
+        ImageType.front_stripe4.value: front_stripe4,
+        ImageType.back_ring.value: back_ring,
+        ImageType.back_gray.value: back_gray,
+        ImageType.back_stripe1.value: back_stripe1,
+        ImageType.back_stripe2.value: back_stripe2,
+        ImageType.back_stripe3.value: back_stripe3,
+        ImageType.back_stripe4.value: back_stripe4,
     }
 
-    valid_main_urls = {k: v for k, v in main_inputs.items() if v and v.strip()}
-    valid_gray_urls = {k: v for k, v in gray_inputs.items() if v and v.strip()}
-
-    provided_main_count = len(valid_main_urls)
-    if strict_mode and provided_main_count != 4:
-        raise HTTPException(status_code=400, detail="严格模式开启,必须提供所有4张主图URL。")
-    if not strict_mode and provided_main_count == 0 and not valid_gray_urls:
+    valid_urls = {k: v for k, v in url_inputs.items() if v and v.strip()}
+    if not valid_urls:
         raise HTTPException(status_code=400, detail="未提供任何图片URL,无法创建。")
 
-    is_reflect_str = "true" if is_reflect_card else "false"
+    logger.info(
+        "auto_import_url_script_api received is_reflect_card=%s strict_mode=%s provided=%s",
+        is_reflect_card,
+        strict_mode,
+        ",".join(valid_urls.keys()),
+    )
 
     connector = aiohttp.TCPConnector(limit=20, force_close=True)
     async with aiohttp.ClientSession(timeout=IMPORT_REQUEST_TIMEOUT, connector=connector) as session:
-        try:
-            logger.info(f"--- 开始URL自动导入任务: {card_name} ---")
-
-            async def fetch_image(img_key: str, img_url: str):
-                try:
-                    async with session.get(img_url) as resp:
-                        if resp.status != 200:
-                            raise HTTPException(status_code=400, detail=f"下载图片失败: {img_key} -> {resp.status}")
-                        file_bytes = await resp.read()
-                        filename = img_url.split('/')[-1].split('?')[0]
-                        if not filename or '.' not in filename:
-                            filename = f"{img_key}.jpg"
-                        return img_key, (file_bytes, filename)
-                except Exception as e:
-                    if isinstance(e, HTTPException): raise e
-                    raise HTTPException(status_code=400, detail=f"访问图片URL异常: {img_key} -> {str(e)}")
-
-            fetch_tasks = [fetch_image(k, url) for k, url in valid_main_urls.items()]
-            fetch_tasks += [fetch_image(k, url) for k, url in valid_gray_urls.items()]
-
-            downloaded_files = await asyncio.gather(*fetch_tasks)
-
-            main_bytes_data = {}
-            gray_bytes_data = {}
-            for key, data in downloaded_files:
-                if key in valid_main_urls:
-                    main_bytes_data[key] = data
-                else:
-                    gray_bytes_data[key] = data
-
-            processed_results = []
-            for img_type, (f_bytes, f_name) in main_bytes_data.items():
-                if len(f_bytes) == 0:
-                    raise HTTPException(status_code=400, detail=f"图片文件 {f_name} 内容为空")
-                res = await process_main_image(session, f_bytes, f_name, img_type, is_reflect_str)
-                processed_results.append(res)
-
-            card_id = await create_card_record(session, local_base_url, card_name, cardNo, card_type)
-            logger.info(f"URL导入卡片记录创建成功,ID: {card_id}")
-
-            # ---------- 修改点:safe_upload_task 调用 + gather ----------
-            upload_tasks = []
-            for res in processed_results:
-                upload_tasks.append(upload_main_image(session, local_base_url, card_id, res))
-
-            for img_type, (f_bytes, f_name) in gray_bytes_data.items():
-                upload_tasks.append(upload_gray_image(session, local_base_url, card_id, img_type, f_bytes, f_name))
-
-            if upload_tasks:
-                await asyncio.gather(*upload_tasks)
-            # ------------------------------------------------------------
-
-            logger.info(f"--- URL自动导入流程结束, Card ID: {card_id} ---")
-            return {
-                "message": "URL导入成功",
-                "card_id": card_id,
-                "card_name": card_name,
-                "cardNo": cardNo
-            }
-
-        except HTTPException:
-            raise
-        except Exception as e:
-            logger.error(f"[URL导入流程终止] 发生异常: {e}")
-            raise HTTPException(status_code=500, detail=f"自动化处理异常: {str(e)}")
+        async def fetch_image(image_type: str, img_url: str) -> Tuple[str, Tuple[bytes, str]]:
+            try:
+                async with session.get(img_url) as resp:
+                    if resp.status != 200:
+                        raise HTTPException(status_code=400, detail=f"下载图片失败: {image_type} -> {resp.status}")
+                    file_bytes = await resp.read()
+                    filename = img_url.split("/")[-1].split("?")[0]
+                    if not filename or "." not in filename:
+                        filename = f"{image_type}.jpg"
+                    return image_type, (file_bytes, filename)
+            except HTTPException:
+                raise
+            except Exception as e:
+                raise HTTPException(status_code=400, detail=f"访问图片URL异常: {image_type} -> {e}")
+
+        downloaded = await asyncio.gather(*[fetch_image(k, u) for k, u in valid_urls.items()])
+
+    bytes_map: Dict[str, Tuple[bytes, str]] = {}
+    for image_type, payload in downloaded:
+        file_bytes, filename = payload
+        if len(file_bytes) == 0:
+            raise HTTPException(status_code=400, detail=f"图片文件 {filename} 内容为空")
+        bytes_map[image_type] = (file_bytes, filename)
+
+    try:
+        return await _run_import_flow(
+            local_base_url=local_base_url,
+            card_name=card_name,
+            cardNo=cardNo,
+            card_type=card_type,
+            strict_mode=strict_mode,
+            bytes_map=bytes_map,
+        )
+    except HTTPException:
+        raise
+    except Exception as e:
+        logger.exception("auto_import_url_script_api failed_unexpected card_name=%s", card_name)
+        raise HTTPException(status_code=500, detail=f"自动化处理异常: {e}")

+ 22 - 9
app/api/images.py

@@ -30,7 +30,8 @@ async def upload_image_for_card(
         image_type: ImageType = Form(..., description="图片类型"),
         image: UploadFile = File(..., description="图片文件"),
         image_name: Optional[str] = Form(None, description="图片的可选名称"),
-        json_data_str: str = Form(..., description="与图片关联的JSON字符串"),
+        json_data_str: Optional[str] = Form(None, description="与图片关联的JSON字符串"),
+        json_data_file: Optional[UploadFile] = File(None, description="与图片关联的JSON文件"),
         db_conn: PooledMySQLConnection = db_dependency
 ):
     """
@@ -40,8 +41,17 @@ async def upload_image_for_card(
     if image_type in [ImageType.front_gray, ImageType.back_gray]:
         raise HTTPException(status_code=400, detail="此接口不支持灰度图上传,请使用 /insert/gray/{card_id}")
 
+    if json_data_file is not None:
+        json_data_bytes = await json_data_file.read()
+        json_payload = json_data_bytes.decode("utf-8")
+    else:
+        json_payload = json_data_str
+
+    if not json_payload:
+        raise HTTPException(status_code=400, detail="缺少JSON数据。")
+
     try:
-        detection_json = json.loads(json_data_str)
+        detection_json = json.loads(json_payload)
     except json.JSONDecodeError:
         raise HTTPException(status_code=400, detail="JSON格式无效。")
 
@@ -131,19 +141,22 @@ async def upload_image_for_card(
 
 
 @router.post("/insert/gray/{card_id}", response_model=CardImageResponse, status_code=201,
-             summary="[NEW] 为卡牌上传辅助灰度图")
+             summary="[NEW] 为卡牌上传辅助图(无JSON)")
 async def upload_gray_image_for_card(
         card_id: int = Path(..., description="要关联的卡牌ID"),
-        image_type: ImageType = Form(..., description="图片类型 (必须是 front_gray 或 back_gray)"),
-        image: UploadFile = File(..., description="灰度图片文件"),
+        image_type: ImageType = Form(..., description="图片类型 (gray / ring / stripe1..4 等无JSON图)"),
+        image: UploadFile = File(..., description="辅助图片文件"),
         db_conn: PooledMySQLConnection = db_dependency
 ):
     """
-    上传辅助灰度图 (front_gray / back_gray)
-    不需要 JSON 数据,不参与直接计算
+    上传无 JSON 的辅助图,例如 gray / ring / stripe1..4
+    融合图带分数 JSON,请改用 /insert/{card_id}
     """
-    if image_type not in [ImageType.front_gray, ImageType.back_gray]:
-        raise HTTPException(status_code=400, detail="此接口仅支持灰度图 (front_gray, back_gray)")
+    if image_type in [ImageType.front_fusion, ImageType.back_fusion]:
+        raise HTTPException(
+            status_code=400,
+            detail="融合图自带分数 JSON,请使用 /insert/{card_id} 接口上传"
+        )
 
     # 1. 保存文件
     file_extension = os.path.splitext(image.filename)[1]

+ 28 - 5
app/utils/scheme.py

@@ -7,19 +7,32 @@ from enum import Enum
 
 
 class ImageType(str, Enum):
+    # 历史同轴光图类型,仅保留兼容旧数据
     front_coaxial = "front_coaxial"
     back_coaxial = "back_coaxial"
+
+    # 环光图
     front_ring = "front_ring"
     back_ring = "back_ring"
 
     # 灰度图类型
     front_gray = "front_gray"
     back_gray = "back_gray"
-    
+
     # 融合图类型
     front_fusion = "front_fusion"
     back_fusion = "back_fusion"
 
+    # 调光(stripe)图,分别对应一面的 4 张
+    front_stripe1 = "front_stripe1"
+    front_stripe2 = "front_stripe2"
+    front_stripe3 = "front_stripe3"
+    front_stripe4 = "front_stripe4"
+    back_stripe1 = "back_stripe1"
+    back_stripe2 = "back_stripe2"
+    back_stripe3 = "back_stripe3"
+    back_stripe4 = "back_stripe4"
+
 
 class ScoreType(str, Enum):
     front_corner_edge = "front_corner_edge"
@@ -59,15 +72,25 @@ class CardNoList(BaseModel):
 
 
 # 图片类型和推理服务 score_type 映射表
+# stitch_score_inference 按 front / back 整面调用,故新版主流程不再依赖这张表。
+# 保留映射主要是为了让旧的编辑/重算逻辑(按单张图)继续可用。
 IMAGE_TYPE_TO_SCORE_TYPE = {
     ImageType.front_coaxial.value: ScoreType.front_face.value,
     ImageType.back_coaxial.value: ScoreType.back_face.value,
     ImageType.front_ring.value: ScoreType.front_corner_edge.value,
     ImageType.back_ring.value: ScoreType.back_corner_edge.value,
-    "front_gray": None,
-    "back_gray": None,
-    "front_fusion": None,
-    "back_fusion": None
+    ImageType.front_gray.value: None,
+    ImageType.back_gray.value: None,
+    ImageType.front_fusion.value: None,
+    ImageType.back_fusion.value: None,
+    ImageType.front_stripe1.value: None,
+    ImageType.front_stripe2.value: None,
+    ImageType.front_stripe3.value: None,
+    ImageType.front_stripe4.value: None,
+    ImageType.back_stripe1.value: None,
+    ImageType.back_stripe2.value: None,
+    ImageType.back_stripe3.value: None,
+    ImageType.back_stripe4.value: None,
 }