auto_import.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764
  1. import json
  2. import asyncio
  3. import aiohttp
  4. from typing import Dict, Any, Tuple, Optional, Union, List
  5. from fastapi import APIRouter, HTTPException, Request, UploadFile, File, Form
  6. from app.core.config import settings
  7. from app.core.logger import get_logger
  8. from app.utils.scheme import CardType, ImageType
  9. logger = get_logger(__name__)
  10. router = APIRouter()
  11. IMPORT_REQUEST_TIMEOUT = aiohttp.ClientTimeout(
  12. total=3600,
  13. connect=30,
  14. sock_connect=30,
  15. sock_read=1800,
  16. )
  17. # 推理服务 stitch 接口需要的表单字段顺序,按 ring + gray + stripe1..4 组装
  18. STITCH_FORM_FIELDS = ["ring", "gray", "stripe1", "stripe2", "stripe3", "stripe4"]
  19. STITCH_DEFECT_KEEP_LABELS = [
  20. "slight_scratch",
  21. "scratch",
  22. "serious_scratch",
  23. "damaged",
  24. "impact",
  25. "pit",
  26. "stain",
  27. "wear",
  28. ]
  29. STITCH_DEFECT_PER_LABEL_LIMIT = 10
  30. STITCH_DEFECT_TOTAL_LIMIT = 80
  31. # 一面(front/back)对应的所有 image_type
  32. SIDE_IMAGE_TYPES: Dict[str, Dict[str, str]] = {
  33. "front": {
  34. "fusion": ImageType.front_fusion.value,
  35. "ring": ImageType.front_ring.value,
  36. "gray": ImageType.front_gray.value,
  37. "stripe1": ImageType.front_stripe1.value,
  38. "stripe2": ImageType.front_stripe2.value,
  39. "stripe3": ImageType.front_stripe3.value,
  40. "stripe4": ImageType.front_stripe4.value,
  41. },
  42. "back": {
  43. "fusion": ImageType.back_fusion.value,
  44. "ring": ImageType.back_ring.value,
  45. "gray": ImageType.back_gray.value,
  46. "stripe1": ImageType.back_stripe1.value,
  47. "stripe2": ImageType.back_stripe2.value,
  48. "stripe3": ImageType.back_stripe3.value,
  49. "stripe4": ImageType.back_stripe4.value,
  50. },
  51. }
  52. # 主表落库的图(带 JSON)
  53. MAIN_IMAGE_TYPES = {ImageType.front_fusion.value, ImageType.back_fusion.value}
  54. def _flat_image_types() -> List[str]:
  55. """按接口参数顺序展开 14 个 image_type,便于日志输出。"""
  56. flat = []
  57. for side in ("front", "back"):
  58. side_map = SIDE_IMAGE_TYPES[side]
  59. flat.extend([
  60. side_map["fusion"],
  61. side_map["ring"],
  62. side_map["gray"],
  63. side_map["stripe1"],
  64. side_map["stripe2"],
  65. side_map["stripe3"],
  66. side_map["stripe4"],
  67. ])
  68. return flat
  69. def _to_float_prob(value: Any) -> float:
  70. try:
  71. return float(value)
  72. except (TypeError, ValueError):
  73. return 0.0
  74. def _trim_stitch_defects(score_json: Dict[str, Any], side: str) -> Dict[str, Any]:
  75. """
  76. stitch 返回缺陷裁剪:
  77. - 仅保留指定 8 个 label
  78. - 每个 label 按 prob 降序最多 10 条
  79. - 总数最多 80 条
  80. """
  81. if not isinstance(score_json, dict):
  82. return score_json
  83. defects = (
  84. score_json.get("result", {})
  85. .get("defect_result", {})
  86. .get("defects", [])
  87. )
  88. if not isinstance(defects, list):
  89. return score_json
  90. by_label: Dict[str, List[Dict[str, Any]]] = {label: [] for label in STITCH_DEFECT_KEEP_LABELS}
  91. for defect in defects:
  92. if not isinstance(defect, dict):
  93. continue
  94. label = defect.get("label")
  95. if label not in by_label:
  96. continue
  97. by_label[label].append(defect)
  98. trimmed: List[Dict[str, Any]] = []
  99. for label in STITCH_DEFECT_KEEP_LABELS:
  100. top_items = sorted(
  101. by_label[label],
  102. key=lambda item: _to_float_prob(item.get("prob")),
  103. reverse=True,
  104. )[:STITCH_DEFECT_PER_LABEL_LIMIT]
  105. trimmed.extend(top_items)
  106. # 总量兜底到 80,按 prob 全局降序截断
  107. trimmed = sorted(
  108. trimmed,
  109. key=lambda item: _to_float_prob(item.get("prob")),
  110. reverse=True,
  111. )[:STITCH_DEFECT_TOTAL_LIMIT]
  112. score_json.setdefault("result", {}).setdefault("defect_result", {})["defects"] = trimmed
  113. logger.info(
  114. "stitch 缺陷裁剪: side=%s before=%s after=%s labels=%s per_label_limit=%s total_limit=%s",
  115. side,
  116. len(defects),
  117. len(trimmed),
  118. ",".join(STITCH_DEFECT_KEEP_LABELS),
  119. STITCH_DEFECT_PER_LABEL_LIMIT,
  120. STITCH_DEFECT_TOTAL_LIMIT,
  121. )
  122. return score_json
  123. async def _pregenerate_defect_images(card_id: int) -> None:
  124. """
  125. 导入完成后预生成缺陷裁图(写入 MinIO),让查询接口直接命中缓存。
  126. 裁图为同步阻塞操作,放到线程池执行;任何异常都不影响导入主流程。
  127. """
  128. def _run() -> None:
  129. from app.core import database_loader
  130. from app.api.formate_xy import pregenerate_defect_images_for_card
  131. if database_loader.db_connection_pool is None:
  132. logger.warning("预生成缺陷裁图跳过:数据库连接池未初始化 card_id=%s", card_id)
  133. return
  134. db_conn = None
  135. try:
  136. db_conn = database_loader.db_connection_pool.get_connection()
  137. pregenerate_defect_images_for_card(db_conn, card_id)
  138. finally:
  139. if db_conn and db_conn.is_connected():
  140. db_conn.close()
  141. try:
  142. from fastapi.concurrency import run_in_threadpool
  143. await run_in_threadpool(_run)
  144. except Exception as e:
  145. logger.error("预生成缺陷裁图失败 card_id=%s error=%s", card_id, e, exc_info=True)
  146. def _resolve_internal_base_url(request: Request) -> str:
  147. """
  148. 导入流程内部调用本服务时优先走集群内地址,避免经 ingress 再次鉴权导致 401。
  149. """
  150. configured = (settings.INTERNAL_API_BASE_URL or "").strip().rstrip("/")
  151. if configured:
  152. return configured
  153. # 默认走本机服务地址,确保不会经过 ingress / oauth 认证链路
  154. return f"http://127.0.0.1:{settings.SERVER_PORT}"
  155. # --- 内部辅助函数 ---
  156. async def _post_form(
  157. session: aiohttp.ClientSession,
  158. url: str,
  159. files: List[Tuple[str, bytes, str] | Tuple[str, bytes, str, str]],
  160. params: Optional[Dict[str, Any]] = None,
  161. form_fields: Optional[Dict[str, Any]] = None,
  162. headers: Optional[Dict[str, str]] = None,
  163. ) -> Tuple[int, bytes]:
  164. """通用 multipart 调用:files 元素为 (field_name, file_bytes, filename[, content_type])。"""
  165. form_data = aiohttp.FormData()
  166. if form_fields:
  167. for key, value in form_fields.items():
  168. form_data.add_field(key, str(value))
  169. for file_item in files:
  170. if len(file_item) == 4:
  171. field_name, file_bytes, filename, content_type = file_item
  172. else:
  173. field_name, file_bytes, filename = file_item
  174. content_type = "image/jpeg"
  175. form_data.add_field(
  176. field_name,
  177. file_bytes,
  178. filename=filename or f"{field_name}.jpg",
  179. content_type=content_type,
  180. )
  181. try:
  182. async with session.post(url, data=form_data, params=params, headers=headers) as response:
  183. response_content = await response.read()
  184. if not response.ok:
  185. logger.error(
  186. "[API Error] %s -> Status: %s, Msg: %s",
  187. url,
  188. response.status,
  189. response_content.decode("utf-8", errors="ignore")[:200],
  190. )
  191. return response.status, response_content
  192. except Exception as e:
  193. logger.error(f"[Conn Error] {url} -> {e}")
  194. raise
  195. async def call_stitch_inference(
  196. session: aiohttp.ClientSession,
  197. side: str,
  198. card_name: str,
  199. side_bytes: Dict[str, Tuple[bytes, str]],
  200. ) -> Dict[str, Any]:
  201. """
  202. 调用 stitch_score_inference 接口,一次处理一面(front/back)。
  203. side_bytes 的 key 必须包含 STITCH_FORM_FIELDS 全部字段,value 是 (bytes, filename)。
  204. """
  205. missing = [k for k in STITCH_FORM_FIELDS if k not in side_bytes]
  206. if missing:
  207. raise HTTPException(
  208. status_code=400,
  209. detail=f"{side} 面缺少推理所需图片字段: {missing}",
  210. )
  211. files: List[Tuple[str, bytes, str]] = []
  212. for field in STITCH_FORM_FIELDS:
  213. f_bytes, f_name = side_bytes[field]
  214. if len(f_bytes) == 0:
  215. raise HTTPException(status_code=400, detail=f"{side} 面 {field} 文件内容为空: {f_name}")
  216. files.append((field, f_bytes, f_name))
  217. inference_base_url = settings.SCORE_UPDATE_SERVER_URL
  218. url = f"{inference_base_url}/api/card_score/stitch_score_inference"
  219. params = {"score_type": side, "card_name": card_name}
  220. logger.info(
  221. "调用 stitch_score_inference: side=%s card_name=%s fields=%s",
  222. side, card_name, ",".join(STITCH_FORM_FIELDS),
  223. )
  224. status, body = await _post_form(session, url=url, files=files, params=params)
  225. if status >= 300:
  226. raise HTTPException(status_code=500, detail=f"{side} 面 stitch 推理失败 status={status}")
  227. try:
  228. return json.loads(body)
  229. except json.JSONDecodeError as e:
  230. raise HTTPException(status_code=500, detail=f"{side} 面推理结果解析失败: {e}")
  231. async def call_rectify_and_center(
  232. session: aiohttp.ClientSession,
  233. image_type: str,
  234. file_bytes: bytes,
  235. filename: str,
  236. ) -> Tuple[bytes, str]:
  237. """调用推理服务转正居中接口,返回转正后的图片 bytes。"""
  238. if len(file_bytes) == 0:
  239. raise HTTPException(status_code=400, detail=f"图片文件 {image_type} 内容为空: {filename}")
  240. inference_base_url = settings.SCORE_UPDATE_SERVER_URL
  241. url = f"{inference_base_url}/api/card_inference/card_rectify_and_center"
  242. logger.info("调用 card_rectify_and_center: image_type=%s filename=%s", image_type, filename)
  243. status, body = await _post_form(
  244. session,
  245. url=url,
  246. files=[("file", file_bytes, filename or f"{image_type}.jpg")],
  247. )
  248. if status >= 300:
  249. raise HTTPException(status_code=500, detail=f"图片转正居中失败: {image_type} status={status}")
  250. name_root = filename.rsplit(".", 1)[0] if filename else image_type
  251. return body, f"{name_root}_rectified.jpg"
  252. async def rectify_side_images(
  253. session: aiohttp.ClientSession,
  254. side: str,
  255. side_bytes: Dict[str, Tuple[bytes, str]],
  256. ) -> Dict[str, Tuple[bytes, str]]:
  257. """将 stitch 需要的一面 6 张图全部转正居中。"""
  258. side_map = SIDE_IMAGE_TYPES[side]
  259. async def rectify_one(field: str) -> Tuple[str, Tuple[bytes, str]]:
  260. file_bytes, filename = side_bytes[field]
  261. image_type = side_map[field]
  262. rectified_bytes, rectified_filename = await call_rectify_and_center(
  263. session=session,
  264. image_type=image_type,
  265. file_bytes=file_bytes,
  266. filename=filename,
  267. )
  268. return field, (rectified_bytes, rectified_filename)
  269. logger.info("%s 面开始转正居中: fields=%s", side, ",".join(STITCH_FORM_FIELDS))
  270. rectified_pairs = await asyncio.gather(*[rectify_one(field) for field in STITCH_FORM_FIELDS])
  271. logger.info("%s 面转正居中完成", side)
  272. return dict(rectified_pairs)
  273. async def create_card_record(
  274. session: aiohttp.ClientSession,
  275. base_url: str,
  276. card_name: str,
  277. cardNo: Optional[str],
  278. card_type: CardType,
  279. forward_headers: Optional[Dict[str, str]] = None,
  280. ) -> int:
  281. """调用自身服务创建新的卡牌记录"""
  282. url = f"{base_url}{settings.API_PREFIX}/cards/created"
  283. params = {"card_name": card_name, "card_type": card_type.value}
  284. if cardNo:
  285. params["cardNo"] = cardNo
  286. async with session.post(url, params=params, headers=forward_headers) as response:
  287. if response.status == 201:
  288. data = await response.json()
  289. return data.get("id")
  290. text = await response.text()
  291. raise HTTPException(status_code=response.status, detail=f"创建卡牌记录失败: {text}")
  292. async def upload_main_image(
  293. session: aiohttp.ClientSession,
  294. base_url: str,
  295. card_id: int,
  296. image_type: str,
  297. file_bytes: bytes,
  298. filename: str,
  299. score_json: Dict[str, Any],
  300. forward_headers: Optional[Dict[str, str]] = None,
  301. ):
  302. """带 JSON 的图走主图入库接口(fusion / ring / stripe 共用同一面的 detection_json)。"""
  303. url = f"{base_url}{settings.API_PREFIX}/images/insert/{card_id}"
  304. form_fields = {
  305. "image_type": image_type,
  306. }
  307. json_bytes = json.dumps(score_json, ensure_ascii=False).encode("utf-8")
  308. status, body = await _post_form(
  309. session,
  310. url=url,
  311. files=[
  312. ("image", file_bytes, filename or f"{image_type}.jpg"),
  313. ("json_data_file", json_bytes, f"{image_type}_score.json", "application/json"),
  314. ],
  315. form_fields=form_fields,
  316. headers=forward_headers,
  317. )
  318. if status != 201:
  319. logger.error(
  320. "[主表图上传失败] image_type=%s status=%s msg=%s",
  321. image_type, status, body.decode("utf-8", errors="ignore")[:200],
  322. )
  323. raise HTTPException(status_code=500, detail=f"主表图保存失败: {image_type}")
  324. async def upload_auxiliary_image(
  325. session: aiohttp.ClientSession,
  326. base_url: str,
  327. card_id: int,
  328. image_type: str,
  329. file_bytes: bytes,
  330. filename: str,
  331. forward_headers: Optional[Dict[str, str]] = None,
  332. ):
  333. """ring / gray / stripe 这些辅助图走灰度图入库接口(不带 JSON)。"""
  334. url = f"{base_url}{settings.API_PREFIX}/images/insert/gray/{card_id}"
  335. form_fields = {"image_type": image_type}
  336. status, body = await _post_form(
  337. session,
  338. url=url,
  339. files=[("image", file_bytes, filename or f"{image_type}.jpg")],
  340. form_fields=form_fields,
  341. headers=forward_headers,
  342. )
  343. if status != 201:
  344. logger.error(
  345. "[辅助图上传失败] image_type=%s status=%s msg=%s",
  346. image_type, status, body.decode("utf-8", errors="ignore")[:200],
  347. )
  348. raise HTTPException(status_code=500, detail=f"辅助图保存失败: {image_type}")
  349. def _collect_side_bytes(
  350. side: str,
  351. bytes_map: Dict[str, Tuple[bytes, str]],
  352. ) -> Optional[Dict[str, Tuple[bytes, str]]]:
  353. """
  354. 根据一面(front/back)的 image_type,从 bytes_map 中抽出 stitch 推理所需的 6 张图。
  355. 如果有任意一张缺失则返回 None,表示该面无法推理。
  356. """
  357. side_map = SIDE_IMAGE_TYPES[side]
  358. side_bytes: Dict[str, Tuple[bytes, str]] = {}
  359. for field in STITCH_FORM_FIELDS:
  360. image_type = side_map[field]
  361. if image_type not in bytes_map:
  362. return None
  363. side_bytes[field] = bytes_map[image_type]
  364. return side_bytes
  365. async def _run_import_flow(
  366. local_base_url: str,
  367. card_name: str,
  368. cardNo: Optional[str],
  369. card_type: CardType,
  370. strict_mode: bool,
  371. bytes_map: Dict[str, Tuple[bytes, str]],
  372. non_gray_to_main: bool = False,
  373. forward_headers: Optional[Dict[str, str]] = None,
  374. ) -> Dict[str, Any]:
  375. """
  376. 共用导入流程:bytes_map 的 key 是 image_type,value 是 (bytes, filename)。
  377. non_gray_to_main:
  378. False -> 仅融合图进主表(带 JSON),ring/gray/stripe 进辅助表(无 JSON)。
  379. True -> 除灰度图外(fusion/ring/stripe)都进主表,并共用所在面的 detection_json;
  380. 灰度图仍进辅助表。
  381. """
  382. expected_types = _flat_image_types()
  383. provided = [t for t in expected_types if t in bytes_map]
  384. missing = [t for t in expected_types if t not in bytes_map]
  385. if strict_mode and missing:
  386. raise HTTPException(
  387. status_code=400,
  388. detail=f"严格模式开启,必须提供全部 14 张图,缺少: {missing}",
  389. )
  390. if not provided:
  391. raise HTTPException(status_code=400, detail="未提供任何图片文件,无法创建。")
  392. logger.info(
  393. "auto_import start card_name=%s cardNo=%s card_type=%s strict_mode=%s provided=%s missing=%s",
  394. card_name,
  395. cardNo or "",
  396. card_type.value,
  397. strict_mode,
  398. ",".join(provided) or "none",
  399. ",".join(missing) or "none",
  400. )
  401. connector = aiohttp.TCPConnector(limit=20, force_close=True)
  402. async with aiohttp.ClientSession(timeout=IMPORT_REQUEST_TIMEOUT, connector=connector) as session:
  403. # 1. 正反面分别调用 stitch 推理(要求该面 6 张推理图齐全)
  404. side_score_json: Dict[str, Optional[Dict[str, Any]]] = {"front": None, "back": None}
  405. for side in ("front", "back"):
  406. side_bytes = _collect_side_bytes(side, bytes_map)
  407. if side_bytes is None:
  408. logger.warning("%s 面推理跳过,缺少 ring/gray/stripe1..4 中的某些图", side)
  409. continue
  410. rectified_side_bytes = await rectify_side_images(
  411. session=session,
  412. side=side,
  413. side_bytes=side_bytes,
  414. )
  415. stitch_result = await call_stitch_inference(
  416. session=session,
  417. side=side,
  418. card_name=card_name,
  419. side_bytes=rectified_side_bytes,
  420. )
  421. side_score_json[side] = _trim_stitch_defects(stitch_result, side)
  422. # 2. 创建卡牌
  423. card_id = await create_card_record(
  424. session,
  425. local_base_url,
  426. card_name,
  427. cardNo,
  428. card_type,
  429. forward_headers=forward_headers,
  430. )
  431. logger.info("auto_import card_created card_name=%s card_id=%s", card_name, card_id)
  432. # 3. 串行上传所有图:避免同时打满本服务的数据库连接池
  433. upload_count = 0
  434. for side in ("front", "back"):
  435. side_map = SIDE_IMAGE_TYPES[side]
  436. score_json = side_score_json.get(side)
  437. # 按 fusion -> ring -> gray -> stripe1..4 顺序处理该面所有图
  438. for field in ["fusion"] + STITCH_FORM_FIELDS:
  439. image_type = side_map[field]
  440. data = bytes_map.get(image_type)
  441. if data is None:
  442. continue
  443. f_bytes, f_name = data
  444. is_gray = (field == "gray")
  445. # 进主表(带该面 detection_json)的条件:
  446. # - 非灰度图
  447. # - 该面有 stitch 结果
  448. # - URL 模式(non_gray_to_main) 下所有非灰度图都进;文件模式仅 fusion 进
  449. to_main = (
  450. not is_gray
  451. and score_json is not None
  452. and (non_gray_to_main or field == "fusion")
  453. )
  454. if to_main:
  455. await upload_main_image(
  456. session, local_base_url, card_id,
  457. image_type, f_bytes, f_name, score_json,
  458. forward_headers=forward_headers,
  459. )
  460. else:
  461. if field == "fusion" and score_json is None:
  462. logger.warning(
  463. "%s 面缺少 stitch 推理结果,融合图 %s 退化为辅助图入库",
  464. side, image_type,
  465. )
  466. await upload_auxiliary_image(
  467. session, local_base_url, card_id,
  468. image_type, f_bytes, f_name,
  469. forward_headers=forward_headers,
  470. )
  471. upload_count += 1
  472. logger.info(
  473. "auto_import finished card_name=%s card_id=%s upload_task_count=%s "
  474. "front_score=%s back_score=%s",
  475. card_name, card_id, upload_count,
  476. "yes" if side_score_json["front"] is not None else "no",
  477. "yes" if side_score_json["back"] is not None else "no",
  478. )
  479. # 导入阶段预生成缺陷裁图,保证后续查询无需实时裁图。
  480. # 失败不影响导入主流程,查询时会自动回退到实时裁图。
  481. await _pregenerate_defect_images(card_id)
  482. return {
  483. "message": "导入成功",
  484. "card_id": card_id,
  485. "card_name": card_name,
  486. "cardNo": cardNo,
  487. }
  488. # --- 暴露的API接口 ---
  489. @router.post("/process_and_import", summary="自动化处理并导入卡牌数据[相机后台调用]")
  490. async def auto_import_script_api(
  491. request: Request,
  492. card_name: str = Form(..., description="卡牌名称"),
  493. cardNo: Optional[str] = Form(None, description="卡牌编号"),
  494. card_type: CardType = Form(CardType.pokemon, description="卡牌类型"),
  495. is_reflect_card: bool = Form(True, description="是否是反光卡(保留参数,兼容旧调用)"),
  496. strict_mode: bool = Form(False, description="如果为True,必须提供所有14张图"),
  497. front_fusion: Union[UploadFile, str, None] = File(None, description="正面融合图"),
  498. back_fusion: Union[UploadFile, str, None] = File(None, description="反面融合图"),
  499. front_ring: Union[UploadFile, str, None] = File(None, description="正面环光图"),
  500. front_gray: Union[UploadFile, str, None] = File(None, description="正面灰度图"),
  501. front_stripe1: Union[UploadFile, str, None] = File(None, description="正面调光图1"),
  502. front_stripe2: Union[UploadFile, str, None] = File(None, description="正面调光图2"),
  503. front_stripe3: Union[UploadFile, str, None] = File(None, description="正面调光图3"),
  504. front_stripe4: Union[UploadFile, str, None] = File(None, description="正面调光图4"),
  505. back_ring: Union[UploadFile, str, None] = File(None, description="反面环光图"),
  506. back_gray: Union[UploadFile, str, None] = File(None, description="反面灰度图"),
  507. back_stripe1: Union[UploadFile, str, None] = File(None, description="反面调光图1"),
  508. back_stripe2: Union[UploadFile, str, None] = File(None, description="反面调光图2"),
  509. back_stripe3: Union[UploadFile, str, None] = File(None, description="反面调光图3"),
  510. back_stripe4: Union[UploadFile, str, None] = File(None, description="反面调光图4"),
  511. ):
  512. """
  513. 按 image_type 直接命名 14 个文件字段:
  514. front_fusion / back_fusion
  515. front_ring / back_ring
  516. front_gray / back_gray
  517. front_stripe1..4 / back_stripe1..4
  518. 推理服务调用 stitch_score_inference,按面(front/back)一次性提交
  519. ring + gray + stripe1..4,返回的 JSON 落到对应 fusion 主图记录中。
  520. """
  521. local_base_url = _resolve_internal_base_url(request)
  522. forward_headers: Dict[str, str] = {}
  523. auth = request.headers.get("authorization")
  524. if auth:
  525. forward_headers["Authorization"] = auth
  526. raw_inputs: Dict[str, Union[UploadFile, str, None]] = {
  527. ImageType.front_fusion.value: front_fusion,
  528. ImageType.back_fusion.value: back_fusion,
  529. ImageType.front_ring.value: front_ring,
  530. ImageType.front_gray.value: front_gray,
  531. ImageType.front_stripe1.value: front_stripe1,
  532. ImageType.front_stripe2.value: front_stripe2,
  533. ImageType.front_stripe3.value: front_stripe3,
  534. ImageType.front_stripe4.value: front_stripe4,
  535. ImageType.back_ring.value: back_ring,
  536. ImageType.back_gray.value: back_gray,
  537. ImageType.back_stripe1.value: back_stripe1,
  538. ImageType.back_stripe2.value: back_stripe2,
  539. ImageType.back_stripe3.value: back_stripe3,
  540. ImageType.back_stripe4.value: back_stripe4,
  541. }
  542. valid_uploads: Dict[str, Any] = {
  543. k: v for k, v in raw_inputs.items()
  544. if getattr(v, "filename", None) and callable(getattr(v, "read", None))
  545. }
  546. # 读取所有字节
  547. bytes_map: Dict[str, Tuple[bytes, str]] = {}
  548. for image_type, upload in valid_uploads.items():
  549. data = await upload.read()
  550. if len(data) == 0:
  551. raise HTTPException(status_code=400, detail=f"图片文件 {image_type} 内容为空")
  552. bytes_map[image_type] = (data, upload.filename)
  553. logger.info(
  554. "auto_import_script_api received is_reflect_card=%s strict_mode=%s provided_count=%s",
  555. is_reflect_card,
  556. strict_mode,
  557. len(bytes_map),
  558. )
  559. try:
  560. return await _run_import_flow(
  561. local_base_url=local_base_url,
  562. card_name=card_name,
  563. cardNo=cardNo,
  564. card_type=card_type,
  565. strict_mode=strict_mode,
  566. bytes_map=bytes_map,
  567. non_gray_to_main=True,
  568. forward_headers=forward_headers or None,
  569. )
  570. except HTTPException as e:
  571. logger.error(
  572. "auto_import_script_api failed_http card_name=%s detail=%s status_code=%s",
  573. card_name, e.detail, e.status_code,
  574. )
  575. raise
  576. except Exception as e:
  577. logger.exception("auto_import_script_api failed_unexpected card_name=%s", card_name)
  578. raise HTTPException(status_code=500, detail=f"自动化处理异常: {e}")
  579. @router.post("/process_and_import_url", summary="通过URL自动化处理并导入卡牌数据")
  580. async def auto_import_url_script_api(
  581. request: Request,
  582. card_name: str = Form(..., description="卡牌名称"),
  583. cardNo: Optional[str] = Form(None, description="卡牌编号"),
  584. card_type: CardType = Form(CardType.pokemon, description="卡牌类型"),
  585. is_reflect_card: bool = Form(True, description="是否是反光卡(保留参数,兼容旧调用)"),
  586. strict_mode: bool = Form(False, description="如果为True,必须提供所有14张图URL"),
  587. front_fusion: Optional[str] = Form(None, description="正面融合图URL"),
  588. back_fusion: Optional[str] = Form(None, description="反面融合图URL"),
  589. front_ring: Optional[str] = Form(None, description="正面环光图URL"),
  590. front_gray: Optional[str] = Form(None, description="正面灰度图URL"),
  591. front_stripe1: Optional[str] = Form(None, description="正面调光图1 URL"),
  592. front_stripe2: Optional[str] = Form(None, description="正面调光图2 URL"),
  593. front_stripe3: Optional[str] = Form(None, description="正面调光图3 URL"),
  594. front_stripe4: Optional[str] = Form(None, description="正面调光图4 URL"),
  595. back_ring: Optional[str] = Form(None, description="反面环光图URL"),
  596. back_gray: Optional[str] = Form(None, description="反面灰度图URL"),
  597. back_stripe1: Optional[str] = Form(None, description="反面调光图1 URL"),
  598. back_stripe2: Optional[str] = Form(None, description="反面调光图2 URL"),
  599. back_stripe3: Optional[str] = Form(None, description="反面调光图3 URL"),
  600. back_stripe4: Optional[str] = Form(None, description="反面调光图4 URL"),
  601. ):
  602. local_base_url = _resolve_internal_base_url(request)
  603. forward_headers: Dict[str, str] = {}
  604. auth = request.headers.get("authorization")
  605. if auth:
  606. forward_headers["Authorization"] = auth
  607. url_inputs: Dict[str, Optional[str]] = {
  608. ImageType.front_fusion.value: front_fusion,
  609. ImageType.back_fusion.value: back_fusion,
  610. ImageType.front_ring.value: front_ring,
  611. ImageType.front_gray.value: front_gray,
  612. ImageType.front_stripe1.value: front_stripe1,
  613. ImageType.front_stripe2.value: front_stripe2,
  614. ImageType.front_stripe3.value: front_stripe3,
  615. ImageType.front_stripe4.value: front_stripe4,
  616. ImageType.back_ring.value: back_ring,
  617. ImageType.back_gray.value: back_gray,
  618. ImageType.back_stripe1.value: back_stripe1,
  619. ImageType.back_stripe2.value: back_stripe2,
  620. ImageType.back_stripe3.value: back_stripe3,
  621. ImageType.back_stripe4.value: back_stripe4,
  622. }
  623. valid_urls = {k: v for k, v in url_inputs.items() if v and v.strip()}
  624. if not valid_urls:
  625. raise HTTPException(status_code=400, detail="未提供任何图片URL,无法创建。")
  626. logger.info(
  627. "auto_import_url_script_api received is_reflect_card=%s strict_mode=%s provided=%s",
  628. is_reflect_card,
  629. strict_mode,
  630. ",".join(valid_urls.keys()),
  631. )
  632. connector = aiohttp.TCPConnector(limit=20, force_close=True)
  633. async with aiohttp.ClientSession(timeout=IMPORT_REQUEST_TIMEOUT, connector=connector) as session:
  634. async def fetch_image(image_type: str, img_url: str) -> Tuple[str, Tuple[bytes, str]]:
  635. try:
  636. async with session.get(img_url) as resp:
  637. if resp.status != 200:
  638. raise HTTPException(status_code=400, detail=f"下载图片失败: {image_type} -> {resp.status}")
  639. file_bytes = await resp.read()
  640. filename = img_url.split("/")[-1].split("?")[0]
  641. if not filename or "." not in filename:
  642. filename = f"{image_type}.jpg"
  643. return image_type, (file_bytes, filename)
  644. except HTTPException:
  645. raise
  646. except Exception as e:
  647. raise HTTPException(status_code=400, detail=f"访问图片URL异常: {image_type} -> {e}")
  648. downloaded = await asyncio.gather(*[fetch_image(k, u) for k, u in valid_urls.items()])
  649. bytes_map: Dict[str, Tuple[bytes, str]] = {}
  650. for image_type, payload in downloaded:
  651. file_bytes, filename = payload
  652. if len(file_bytes) == 0:
  653. raise HTTPException(status_code=400, detail=f"图片文件 {filename} 内容为空")
  654. bytes_map[image_type] = (file_bytes, filename)
  655. try:
  656. return await _run_import_flow(
  657. local_base_url=local_base_url,
  658. card_name=card_name,
  659. cardNo=cardNo,
  660. card_type=card_type,
  661. strict_mode=strict_mode,
  662. bytes_map=bytes_map,
  663. non_gray_to_main=True,
  664. forward_headers=forward_headers or None,
  665. )
  666. except HTTPException:
  667. raise
  668. except Exception as e:
  669. logger.exception("auto_import_url_script_api failed_unexpected card_name=%s", card_name)
  670. raise HTTPException(status_code=500, detail=f"自动化处理异常: {e}")